Skip to main content
On Tassyir Hosting your app’s code runs on Tassyir, on Cloudflare Workers: there is no server for you to run. It is the same app as one on your own server: the same manifest, scopes, consent, Admin API, webhooks and App Bridge. Only where the code runs changes, and an app may move between the two from one version to the next. Tassyir Hosting is not open yet. Until it opens, the API refuses a hosted version with APP_HOSTING_NOT_AVAILABLE and tassyir deploy says so; run your app on your own server meanwhile (hosting.mode = "external"). This guide is the contract hosting opens with, so you can build for it now.

What a hosted app is

A hosted app sets hosting.mode = "tassyir" and lists every host its code calls in allowed_hosts (an empty list when it calls none):
  • Its address is https://{handle}.tassyir-apps.com, and admin.page and each webhook uri are paths on it. A hosted app has no hosting.url, no auth.callback_url and no absolute webhook uri.
  • It answers only inside the dashboard. The dashboard opens your page in its frame with a session it started for that member and that installation. A visit to the address in a browser tab, a frame on another site, or a request from another app gets a short refusal, never your page.
  • /.tassyir/ is Tassyir’s. On a hosted app’s origin, /.tassyir and every path below it are answered by Tassyir itself (that is where the dashboard’s session starts). The manifest refuses admin.page or a webhook uri there (MANIFEST_PATH_RESERVED), and a link of yours to it leads nowhere.
  • Each installation runs the version its merchant accepted, the same as for an app on your server: a new version is offered to each installation as an update (Scopes and consent). Only a version that passed its scan and was released runs, on a merchant’s business and on a dev store alike.

The code

The code is one ES module whose default export has a fetch handler, the Workers way. Everything about the installation arrives in ctx.props, per request; env is empty:
STORAGE and TASSYIR are capabilities: they act for this request’s installation, and your code can neither read nor change which one. Keep per-request state per request. One copy of your code serves every installation that runs the same version, possibly several merchants in one process: a variable at the top of a module is shared between them. Never cache a merchant’s data in one; use STORAGE or the request. Change things only on a write. Never change state on a GET: change it on POST, PUT, PATCH or DELETE from your own page, which are refused from anywhere else. Your page’s requests never carry the dashboard’s session, and a cookie your app sets stays on your app’s host. Do not use your own cookies as a CSRF defence (a double-submit cookie): another app’s page may be able to set a cookie for the whole tassyir-apps.com domain. Limits per request: 300 ms of CPU and 50 subrequests (each fetch, each call to STORAGE or TASSYIR). Past either, the request fails, and the merchant’s frame shows an error.

Deploy with the CLI

tassyir deploy does everything in one command, in the folder that holds tassyir.app.toml:
  1. Build. It bundles your Worker’s entry (--entry <file>, else the first of src/worker.ts, src/worker.js, src/index.ts, src/index.js) into one ES module, worker.js. .html and .css files import as text (import page from './page.html' is a string).
  2. Check, before anything is sent. The bundle rules below and the scan’s checks that need nothing of Tassyir’s: every problem at once, each at its place in your source (src/worker.ts:12), exit code 4. Nothing is uploaded until they pass.
  3. Upload the manifest and the bundle, with your lockfile (package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock or bun.lock), which is scanned and never deployed.
  4. Follow the scan until the version is released or refused, then exit by the verdict.
The hosted template of create-tassyir-app (Hono and React) starts a project that deploys as it is. With --json, deploy prints one line when the version is uploaded (its number, and the command to follow it) and one with the verdict, so a tool whose own call times out still knows which version to check:
tassyir app version [n] shows a version (the latest by default) with its scan; --follow waits for the verdict and exits as deploy does.

The bundle rules

The same manifest and bundle as the latest version are not a new version: deploy answers with that version’s state instead.

The scan

Every hosted version is scanned before it can run, outside Tassyir’s servers. Each check passes, warns (the version still goes ahead) or fails (it does not): An AI review then reads the code against the manifest. It can only add caution: it fails a version, or holds it for a person at Tassyir, but it never passes a version a check failed. Each finding names its rule, severity (error fails, warning does not), file, line and a message. The checks read the code and never run it: a host or a path built at run time is left to the limits at run time (egress, the installation’s scopes).

Where a version stands

tassyir app version shows it, and deploy exits by it:

Call the Admin API

ctx.props.TASSYIR.fetch calls the Admin API as the installation, with no token: the same routes, scopes, store access and answers as an access token of the installation, from the Admin API and its reference.
  • Only Admin API paths: /apps/<version>/ and segments of letters, digits, _ and -; no other path, no //, trailing /, % or ;. The URL’s host does not matter: the call always goes to Tassyir’s API.
  • Only GET, POST, PUT, PATCH and DELETE, and only a JSON body of at most 100 KB. It is re-serialised before it is sent, so send ids as strings: a number past 2^53 would be rounded (the Admin API’s ids always are strings).
  • Only three of your headers pass: Accept, Content-Type and Idempotency-Key (1 to 255 printable ASCII characters). Retrying a write with the same key is safe, as for any app.
  • The answer is the API’s, at most 8 MiB, with Content-Type, the RateLimit-* headers, Retry-After, Idempotent-Replayed and X-Request-Id. Ask for less with limit or ?fields=.
  • The installation acts, never the viewer: the activity log names your app. Rate limits are the installation’s, as for any app.
Some refusals come from the binding itself, before the call reaches the API, in the same problem shape without a requestId: HOSTED_PATH_NOT_ALLOWED, RESPONSE_TOO_LARGE, TASSYIR_BINDING_NOT_AVAILABLE, and METHOD_NOT_ALLOWED, BAD_REQUEST, IDEMPOTENCY_KEY_INVALID, PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, SERVICE_UNAVAILABLE (the API did not answer in 30 seconds). The API answers HOSTED_CALL_REFUSED (401) when the installation can no longer call it: uninstalled, suspended, or no longer on a released version. @tassyir/app’s typed Admin API client can send its requests through the binding too: its fetch is ctx.props.TASSYIR.fetch, and it needs no access token.

Storage

Each installation has its own SQLite database, ctx.props.STORAGE:
  • Limits: 50 MiB per installation; a query of at most 100,000 characters; at most 100 bound values, 1 MiB together; a batch of at most 50 statements; at most 5,000 rows and about 4 MiB in an answer (page with LIMIT).
  • Each call is one transaction: a statement that fails, an answer too large or a database grown past its size rolls back everything the call wrote. Reads and deletes always run, so a full database can be emptied. BEGIN, COMMIT and most PRAGMAs are refused.
  • Errors are thrown with a message that starts with STORAGE_FULL, STORAGE_LIMIT, STORAGE_INVALID or STORAGE_UNAVAILABLE, or SQLite’s own message.
  • It is deleted 48 hours after the app is uninstalled, with the installation’s app data (Compliance and uninstall).

Call other services

Your code’s fetch() reaches the hosts of allowed_hosts only: an exact name, or *. + a name for its subdomains (never the name itself), over https on port 443, with no user or password in the URL. Everything else is refused with a 403 whose tassyir-egress-refused header says why (not_allowed, tassyir_host, not_https, port, credentials_in_url, ip_literal, private_name); the request is never sent. Tassyir’s own domains are always refused: call Tassyir through TASSYIR. A redirect is not followed: your code gets the 3xx, and following it is a new request, checked again. Sockets are not available. The merchant sees every host in allowed_hosts on the install screen, and a version that adds one asks for their consent again.

Webhooks

A hosted app receives the same deliveries as an app on your server, at https://{handle}.tassyir-apps.com + the webhook uri: the same headers, the same signed body (Webhooks). Your handler runs with viewer set to null, with the installation’s STORAGE and TASSYIR. Answer within 10 seconds; only your status and Retry-After are read. An exception, or a request past its limits, counts as a failed attempt and is retried. Verify each delivery with verifyWebhook. Your code cannot fetch the key document (GET https://api.tassyir.io/apps/webhook-keys: Tassyir’s hosts are refused, and TASSYIR calls only Admin API paths), so give it the keys that document lists:
app/uninstalled and business/redact reach an uninstalled installation too, as they reach an app on your server. During those two deliveries TASSYIR answers HOSTED_CALL_REFUSED (the installation is no longer active). STORAGE still holds everything for app/uninstalled (it is deleted 48 hours later) and nothing for business/redact. Clean up what you keep elsewhere, through fetch() to your allowed_hosts.

Checklist

  • Every host the code calls is in allowed_hosts; nothing calls Tassyir but ctx.props.TASSYIR.
  • No node: import, and the entry exports a fetch handler.
  • No page, webhook or link under /.tassyir/.
  • No merchant data in module-level variables.
  • Nothing changes on a GET.
  • Ids are strings in every body.
  • tassyir deploy passes its checks before it uploads; the scan’s findings are fixed, not silenced.