Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ Möbius is meant to be self-hosted on a user-provisioned host — a managed plat

Two invariants follow. (1) **Möbius never patches the kernel from inside the container** — it only *surfaces* "host reboot pending / kernel CVE outstanding" to the owner; the platform/OS applies it. (2) **The in-container agent cannot recreate its own container** (the swap would kill its own process), so the shape is *propose-in* (agent scans → bumps → tests → commits) / *dispose-out* (a host-driven `deploy-prod.sh`, or blue-green, does the rebuild+recreate). Detection is the agent's leverage on every tier: `pip-audit` + `npm audit` + an image scanner (Trivy / `docker scout`) over the built image → triage → bump → test → deploy (tier 1) or surface a reboot window (tiers 2/3).

**The in-product platform updater does not update host deployment files.**
Settings advances the served `/data/platform` clone inside the app volume; the
bundled self-hosted Caddy service instead reads `./Caddyfile` from the host
checkout, and image/dependency changes likewise need a host rebuild/recreate.
An incoming change to `Caddyfile`, `docker-compose.yml`, `Dockerfile`, or a
dependency manifest therefore carries a separate host action (and may require a
particular ordering) even when the live clone rebases cleanly. Never describe
Apply + server restart alone as activating those files.

**lodash is pinned to 4.18.1 via `overrides`.** `@openai/apps-sdk-ui` pulls lodash transitively — only through its `Slider` component, which the shell does not import. The 4.17.x line sat unfixed against several advisories for a long stretch; 4.18.x restored maintenance and patched them, so `frontend/package.json` `overrides` forces the transitive lodash to 4.18.1 (`npm audit` is clean). As defense-in-depth, `frontend/src/lib/__tests__/appsSdkLodash.test.js` also fails if the shell ever imports `Slider`, which keeps lodash tree-shaken out of the shipped bundle regardless of the pin.

## Self-update model — `upstream` / `main`, replay on update
Expand Down
31 changes: 27 additions & 4 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ model, lifecycle rules, and escape hatches.
it collapses the boundary it is meant to protect. A deliberately trusted app
must receive its own origin or become an explicit platform extension.

## Credentials and authority

Opacity removes the shell's ambient **owner** authority; it does not make an
ordinary app powerless. In the current transport, the runtime receives a
refreshable app-scoped bearer and app code in that realm must be treated as able
to possess it. The bearer is narrower than the owner JWT: its app id,
installation nonce, owner epoch and installed permissions are rechecked by the
server.

Credential placement and granted authority are separate decisions. A future
host-mediated request transport could keep the raw app bearer in the exact
parent while still letting the live frame invoke every server operation its
installed permissions allow. That would reduce theft and replay outside the
frame; it would not reduce the app's approved authority while the frame is
running. A generic request transport also need not become one bespoke wrapper
per API route — server permissions remain the authorization contract.

This does not replace lifecycle-aware browser capabilities or the trust tiers
above. Origin-bound facilities such as cookies, service workers and durable
origin storage still require a host provider or a separate service origin; a
raw general shell-origin bridge would recreate the authority opacity removed.

## Manifest contract

Runtime capabilities live in the root `capabilities` object:
Expand Down Expand Up @@ -224,11 +246,12 @@ Add primitives only after a real app needs them. Likely families are:
- `device.midi`, `device.serial`, `device.bluetooth`
- `display.fullscreen`, `display.wake_lock`

External HTTP remains an app-token authenticated server surface rather than a
host session. Its reviewable permission should describe destinations and
Today external HTTP remains an app-token-authenticated server surface rather
than a host session. Its reviewable permission should describe destinations and
methods; wildcard access can remain possible through explicit owner approval.
Likewise, app storage and cross-app access are durable server capabilities, not
browser-session providers.
Moving credential possession into a generic host request transport would not
change that authorization model. Likewise, app storage and cross-app access are
durable server capabilities, not browser-session providers.

## Adding a capability

Expand Down
14 changes: 10 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ being external attackers reaching the public HTTPS endpoint.
- **Mini-app isolation and tokens:** shell-mounted app frames omit
`allow-same-origin`, giving them an opaque origin. They cannot read shell
localStorage or the owner JWT. Each receives a refreshable app JWT bound to
the live app id, installation nonce and owner token epoch; server routes
enforce the app's exact permissions.
the live app id, installation nonce and owner token epoch; app code must be
treated as able to possess that narrower bearer, and server routes enforce
the app's exact installed permissions. Opacity protects ambient **owner**
authority — it is not a promise that ordinary app code never sees its own
scoped credential.
- **Rate limiting:** 120 req/min global, 3-5/min on auth endpoints.
Uses TCP peer address (not X-Forwarded-For).

Expand All @@ -47,8 +50,11 @@ These are intentional design decisions appropriate for a single-owner app:
outer PWA shell which hosts the existing opaque app-frame protocol. Until
then, standalone launch must not be presented as isolated from owner storage.
- **`null` CORS origin:** Required for sandboxed mini-app iframes to call
the API. Mitigated by scoped tokens — even if a mini-app reads the
iframe's token, it can only access storage/proxy/AI endpoints.
the API. Mitigated by scoped tokens — even if a mini-app reads or copies its
bearer, it can reach only routes authorized by that app's installed
permissions, live installation nonce and owner token epoch. Keep this stated
in terms of the principal rather than an endpoint list: app-authorized routes
evolve, and there is no synchronous `/api/ai` surface.
- **`unsafe-inline` in style-src CSP:** Required for server-injected theme
CSS. The owner controls the theme content.
- **90-day service token:** Used by cron scripts. Stored at
Expand Down
105 changes: 105 additions & 0 deletions backend/app/app_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Durable per-app unread activity derived from app-attributed notifications."""

from sqlalchemy import update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app import models
from app.timeutil import now_naive_utc


def mark_from_notification(
db: Session, *, source_type: str, source_id: str | None,
) -> int | None:
"""Mark a live app unread inside the caller's notification transaction.

Returns the app id when a durable marker was written. The update-first,
savepoint-backed insert is race-safe when an existing app receives its first
two notifications concurrently, without committing the caller's transaction.
"""
if source_type != "app" or source_id is None:
return None
try:
app_id = int(source_id)
except (TypeError, ValueError):
return None
# SQLite INTEGER keys are signed 64-bit values. Reject values outside that
# bindable range before ``Session.get`` so malformed attribution cannot turn
# an otherwise valid notification into a persistence failure.
if app_id <= 0 or app_id > (2**63 - 1):
return None
app = db.get(models.App, app_id)
if app is None or app.deleted_at is not None:
return None

now = now_naive_utc()
result = db.execute(
update(models.AppActivityState)
.where(models.AppActivityState.app_id == app_id)
.values(
activity_at=now,
activity_version=models.AppActivityState.activity_version + 1,
unseen=True,
)
)
if result.rowcount:
return app_id

try:
with db.begin_nested():
db.add(models.AppActivityState(
app_id=app_id, activity_at=now, unseen=True,
))
db.flush()
except IntegrityError:
# Another first notification inserted the singleton row after our UPDATE.
# The savepoint kept the outer Notification insert intact; make this event
# the winning latest marker.
db.execute(
update(models.AppActivityState)
.where(models.AppActivityState.app_id == app_id)
.values(
activity_at=now,
activity_version=models.AppActivityState.activity_version + 1,
unseen=True,
)
)
return app_id


def mark_seen(db: Session, app_id: int, seen_through_version: int) -> None:
"""Acknowledge only activity the opening shell actually observed.

A newer notification can race the acknowledgement request. Bounding the
update by its observed monotonic version keeps that newer event unread instead of
letting a late acknowledgement erase it.
"""
db.execute(
update(models.AppActivityState)
.where(
models.AppActivityState.app_id == app_id,
models.AppActivityState.activity_version <= seen_through_version,
)
.values(unseen=False)
)


def annotate_apps(db: Session, apps: list[models.App]) -> list[models.App]:
"""Attach the response-only ``has_unseen_activity`` flag to app rows."""
ids = [app.id for app in apps]
unseen_by_id = {}
if ids:
unseen_by_id = {
row.app_id: row.activity_version
for row in db.query(
models.AppActivityState.app_id,
models.AppActivityState.activity_version,
).filter(
models.AppActivityState.app_id.in_(ids),
models.AppActivityState.unseen.is_(True),
).all()
}
for app in apps:
app.has_unseen_activity = app.id in unseen_by_id
app.unseen_activity_version = unseen_by_id.get(app.id)
return apps
9 changes: 8 additions & 1 deletion backend/app/app_compile_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,17 @@ def runtime_library_aliases() -> tuple[tuple[str, Path], ...]:
``app_runtime_inject.js``. React then sees two dispatchers and every hook
fails at first render. Package-root aliases apply to the root and its subpaths
(for example ``react/jsx-runtime``), keeping each supported library singular.

Three's documented ``three/addons/*`` export is backed by the physical
``examples/jsm`` directory rather than an ``addons`` directory. Once the
package root is replaced with an absolute alias, esbuild no longer consults
Three's package exports for that subpath. Pin the public addons spelling to
its runtime-owned physical directory before adding the package roots.
"""
node_path = runtime_node_path()
roots = sorted({_package_root(specifier) for specifier in BUNDLED_RUNTIME_LIBS})
return tuple((root, node_path / root) for root in roots)
subpaths = (("three/addons", node_path / "three" / "examples" / "jsm"),)
return subpaths + tuple((root, node_path / root) for root in roots)


NO_DEFAULT_EXPORT_ERROR = (
Expand Down
17 changes: 17 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,23 @@ class App(Base):
)


class AppActivityState(Base):
"""Durable unread-activity marker for one installed app.

This deliberately lives outside ``apps``: acknowledging a report must not
advance ``App.updated_at``, which is the shell's executable-bundle cache key.
Notifications remain the detailed history; the drawer only needs one compact
unread/read row per app.
"""

__tablename__ = "app_activity_state"

app_id = Column(Integer, ForeignKey("apps.id"), primary_key=True)
activity_at = Column(DateTime, nullable=False, default=lambda: now_naive_utc())
activity_version = Column(Integer, nullable=False, default=1, server_default="1")
unseen = Column(Boolean, nullable=False, default=True, server_default=true())


class PushSubscription(Base):
"""Browser push subscription for Web Push delivery."""

Expand Down
Loading
Loading