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 setshosting.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, andadmin.pageand each webhookuriare paths on it. A hosted app has nohosting.url, noauth.callback_urland no absolute webhookuri. - 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,/.tassyirand every path below it are answered by Tassyir itself (that is where the dashboard’s session starts). The manifest refusesadmin.pageor a webhookurithere (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 afetch 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:
- Build. It bundles your Worker’s entry (
--entry <file>, else the first ofsrc/worker.ts,src/worker.js,src/index.ts,src/index.js) into one ES module,worker.js..htmland.cssfiles import as text (import page from './page.html'is a string). - 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. - Upload the manifest and the bundle, with your lockfile
(
package-lock.json,npm-shrinkwrap.json,pnpm-lock.yaml,yarn.lockorbun.lock), which is scanned and never deployed. - Follow the scan until the version is released or refused, then exit by the verdict.
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-TypeandIdempotency-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, theRateLimit-*headers,Retry-After,Idempotent-ReplayedandX-Request-Id. Ask for less withlimitor?fields=. - The installation acts, never the viewer: the activity log names your app. Rate limits are the installation’s, as for any app.
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,COMMITand mostPRAGMAs are refused. - Errors are thrown with a message that starts with
STORAGE_FULL,STORAGE_LIMIT,STORAGE_INVALIDorSTORAGE_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’sfetch() 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, athttps://{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 butctx.props.TASSYIR. - No
node:import, and the entry exports afetchhandler. - 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 deploypasses its checks before it uploads; the scan’s findings are fixed, not silenced.