# Z-Arch Runtime Library

Z-Arch Runtime Library (`zarch`) provides the authentication primitives (`ZArchAuth`) used by services running within the Z-Arch architecture. It exposes a stable Python API for encrypted session cookies, service-to-service trust, and the `ZArchExtension` interface with lifecycle hooks used during bootstrap and deployment workflows with the [Z-Arch CLI](https://zarch.ramcloudcode.com).


## Quick Start: `ZArchAuth`

Use `ZArchAuth` in real services to:
- run the session service endpoints (`/session`, `/session/login`, `/session/logout`, `/session/verify`)
- let the Z-Arch Gateway own end-user auth verification in normal Z-Arch deployments
- add optional stateful session hooks for revocation and backend session control

```python
from zarch import ZArchAuth

auth = ZArchAuth()

# Session service entrypoint.
# In a standard Z-Arch deployment, the gateway validates JWT + session cookie
# before protected traffic reaches your business services.
app = auth.session.start()
```

Common deployment pattern:
- Keep the session service separate from business services.
- Let Z-Arch Gateway enforce end-user auth; app services focus on business logic.
- Use `ZArchAuth.s2s.sign(...)` and `ZArchAuth.s2s.verify(...)` for internal service trust.
- Use direct `ZArchAuth.session.verify(...)` in application code only for custom/non-standard topologies.

### Session Mode: Stateless by Default

Session cookies are stateless by default. If no hooks are registered, cryptographic cookie validation is enough and `/session/verify` defaults to valid after payload checks.

To enable stateful behavior (revocation, server-side deny lists, tenant-specific controls), register these hooks:
- `on_login(sid, uid, tenant, iat, exp)` to persist session state
- `on_logout(sid, uid, tenant)` to revoke state
- `on_verify(sid, uid, tenant, iat, exp) -> bool` to allow/deny each session

Real-world pattern:
- hash `sid` before storage
- persist session records on login
- mark `revoked_at` on logout (idempotent)
- deny in `on_verify` when revoked, missing, or expired

```python
from zarch import ZArchAuth
from google.cloud import firestore
from datetime import datetime, timezone
import hashlib
import time

auth = ZArchAuth()
db = firestore.Client()

def _hash_sid(sid: str) -> str:
    return hashlib.sha256(sid.encode()).hexdigest()

def on_login(sid: str, uid: str, tenant: str | None, iat: int, exp: int) -> None:
    db.collection("zarch_sessions").document(_hash_sid(sid)).set({
        "uid": uid,
        "tenant": tenant,
        "created_at": datetime.fromtimestamp(iat, tz=timezone.utc),
        "expires_at": datetime.fromtimestamp(exp, tz=timezone.utc),
        "revoked_at": None,
    })

def on_logout(sid: str, uid: str, tenant: str | None) -> None:
    db.collection("zarch_sessions").document(_hash_sid(sid)).set({
        "revoked_at": datetime.now(tz=timezone.utc),
    }, merge=True)

def on_verify(sid: str, uid: str, tenant: str | None, iat: int, exp: int) -> bool:
    doc = db.collection("zarch_sessions").document(_hash_sid(sid)).get()
    if not doc.exists:
        return False
    data = doc.to_dict()
    if data.get("revoked_at") is not None:
        return False
    expires_at = data.get("expires_at")
    return bool(expires_at and expires_at.timestamp() >= time.time())

auth.session.register_hook("on_login", on_login)
auth.session.register_hook("on_logout", on_logout)
auth.session.register_hook("on_verify", on_verify)
app = auth.session.start()
```
---
## Security Model Summary

- Session cookies are encrypted and stateless by default; stateful controls are added explicitly via `on_login`, `on_logout`, and `on_verify` hooks.
- In the intended Z-Arch platform flow, gateway/session components handle end-user auth so application services do not need to implement cookie auth logic directly.
- `ZArchAuth.s2s.sign(...)` and `ZArchAuth.s2s.verify(...)` enforce short-lived signed service-to-service trust with explicit caller/target validation.
- Auth helpers fail closed: invalid, expired, tampered, or unauthorized credentials raise errors that should map to `401`/`403`.
- Secret material (cookie encryption keys, S2S keys, API credentials) should come from secure secret management and never be hardcoded or logged.

---

# Z-Arch Extensions: Project Context Interface

This document describes the **extension-facing interface** exposed to extensions through the `project_context` argument passed into lifecycle hooks. This is the stable API extensions should use. It is intentionally narrow, safe, and versioned by Z-Arch.

If you are authoring an extension, you should **only** access functionality via `project_context` (not internal modules).

---

## Quick Start

A minimal extension looks like:

```python
from typing import Any, Dict
from zarch.extensions.base import ZArchExtension

class Extension(ZArchExtension):
    def claim(self, extension_name: str, extension_block: Dict[str, Any]) -> bool:
        return extension_block.get("type") == "example"

    def on_post_deploy(self, project_context, extension_configuration: Dict[str, Any]) -> None:
        project_context.log("Hello from my extension!")
```

The `project_context` object is your primary tool. It provides:
- project metadata (project ID, region, repo path)
- config accessors
- safe prompt helpers
- GCP helpers (secrets, service URLs, env vars, service accounts)
- GitHub and Cloudflare helpers

---

## Lifecycle Hooks (from `ZArchExtension`)

Extensions can implement any subset of these methods. Each hook receives `project_context` and the extension-specific configuration block.

### `claim(extension_name, extension_block) -> bool`
Return `True` if your extension should handle this extension block in `zarch.yaml`.

**Example**
```python
    def claim(self, extension_name: str, extension_block: Dict[str, Any]) -> bool:
        return extension_block.get("type") == "my-extension"
```

### `pre_project_bootstrap(project_context, extension_configuration)`
Runs before initial project bootstrap, but after prompting and repo cloning.

**Example**
```python
    def pre_project_bootstrap(self, project_context, extension_configuration):
        project_context.log("Preparing custom bootstrap")
```

### `post_project_bootstrap(project_context, extension_configuration)`
Runs after initial project bootstrap.

**Example**
```python
    def post_project_bootstrap(self, project_context, extension_configuration):
        domain = project_context.config_get("domain")
        project_context.log(f"Project domain is {domain}")
```


### `pre_service_deploy(project_context, extension_configuration)`
Runs before a Cloud Run service is deployed.

**Example**
```python
    def pre_service_deploy(self, project_context, extension_configuration):
        project_context.log("Preparing service deployment")
```

### `post_service_deploy(project_context, extension_configuration)`
Runs after a Cloud Run service has been deployed.

**Example**
```python
    def post_service_deploy(self, project_context, extension_configuration):
        project_context.log("Service deployed successfully")
```

### `pre_gateway_deploy(project_context, extension_configuration)`
Runs before the Z-Arch gateway is deployed.

**Example**
```python
    def pre_gateway_deploy(self, project_context, extension_configuration):
        project_context.log("Preparing gateway deployment")
```

### `post_gateway_deploy(project_context, extension_configuration)`
Runs after the Z-Arch gateway has been deployed.

**Example**
```python
    def post_gateway_deploy(self, project_context, extension_configuration):
        project_context.log("Gateway deployed successfully")
```

### `pre_job_deploy(project_context, extension_configuration)`
Runs before a Cloud Run job is deployed.

**Example**
```python
    def pre_job_deploy(self, project_context, extension_configuration):
        project_context.log("Preparing job deployment")
```

### `post_job_deploy(project_context, extension_configuration)`
Runs after a Cloud Run job has been deployed.

**Example**
```python
    def post_job_deploy(self, project_context, extension_configuration):
        job_id = project_context.config_get("jobs[0].id")
        project_context.log(f"Job {job_id} deployed successfully")
```

### `pre_scheduler_deploy(project_context, extension_configuration)`
Runs before a Cloud Scheduler job is deployed.

**Example**
```python
    def pre_scheduler_deploy(self, project_context, extension_configuration):
        project_context.log("Preparing scheduler deployment")
```

### `post_scheduler_deploy(project_context, extension_configuration)`
Runs after a Cloud Scheduler job has been deployed.

**Example**
```python
    def post_scheduler_deploy(self, project_context, extension_configuration):
        scheduler_id = project_context.config_get("schedulers[0].id")
        project_context.log(f"Scheduler {scheduler_id} deployed successfully")
```

### `pre_topic_deploy(project_context, extension_configuration)`
Runs before a Pub/Sub topic is deployed.

**Example**
```python
    def pre_topic_deploy(self, project_context, extension_configuration):
        project_context.log("Preparing topic deployment")
```

### `post_topic_deploy(project_context, extension_configuration)`
Runs after a Pub/Sub topic has been deployed.

**Example**
```python
    def post_topic_deploy(self, project_context, extension_configuration):
        topic_id = project_context.config_get("topics[0].id")
        project_context.log(f"Topic {topic_id} deployed successfully")
```

## Manual Hook Triggering

Use the CLI to manually dispatch lifecycle hooks for configured extensions:

```bash
zarch ext trigger pre_service_deploy
zarch ext trigger post_service_deploy --extension my-extension
zarch ext trigger post_gateway_deploy --extension audit --extension cache
```

- `hook_name` must be one of the lifecycle hooks defined by `ZArchExtension`.
- `--extension` is optional and can be repeated. Values must match extension block names under `extensions:` in `zarch.yaml`.
- Without `--extension`, all configured extension blocks are considered, and only installed extensions that claim those blocks are invoked.
- Dispatch follows the normal hook execution policy (`local` vs `remote`) used by live deployments.
- Manual dispatches include event metadata where `source` is set to `"manual"`, allowing extensions to branch safely.

---

## `project_context` Interface

The sections below describe **all available attributes and methods** exposed to extensions. Use them as the primary API surface.

### Core Attributes

These attributes represent the current project state in a safe, read-only form.

- `project_context.id` (str)
  - The active GCP project ID.
  - Example: `"my-gcp-project"`

  **Example**
  ```python
  project_id = project_context.id
  project_context.log(f"Deploying project {project_id}")
  ```

- `project_context.region` (str)
  - The active region for this deployment run.
  - Example: `"us-east1"`

  **Example**
  ```python
  region = project_context.region
  project_context.log(f"Active region: {region}")
  ```

- `project_context.project_root_path` (`pathlib.Path`)
  - Absolute path to the project root directory.

  **Example**
  ```python
  root = project_context.project_root_path
  project_context.log(f"Root path: {root}")
  ```

- `project_context.non_interactive` (bool)
  - True if Z-Arch is running in non-interactive mode.

  **Example**
  ```python
  if project_context.non_interactive:
      project_context.log("Running non-interactively")
  ```

- `project_context.config` (`zarch_cli.helpers.config.Config`)
  - The loaded Z-Arch config object.
  - Most extensions should use the `config_get`, `config_set`, and `config_save` helpers instead of accessing `config` directly.

  **Example**
  ```python
  cfg = project_context.config
  project_context.log(f"Config loaded from: {cfg.root}")
  ```

### Event Metadata

#### `get_event_data() -> dict[str, Any] | None`
Read optional metadata for the lifecycle hook currently being dispatched.

- This may be `None` when metadata is unavailable.
- Keys are additive and may grow over time; extensions should tolerate unknown keys.
- Known envelope fields include:
  - `schema_version` (integer)
  - `source` (`"live"` or `"manual"`)
  - `hook` (hook name)
  - `timestamp` (UTC ISO-8601)
  - `resource` (e.g. kind/id/region)
  - `payload` (hook-specific details, may be empty)

**Example**
```python
event = project_context.get_event_data() or {}
source = event.get("source", "unknown")
if source == "manual":
    project_context.log("Skipping side-effect for manual trigger", level="warn")
```

---

### Logging

#### `log(message: str, level: str | None = None) -> None`
Write a styled message to the Z-Arch console.

- `level` is optional and used only to tag the message (e.g. "INFO", "WARN").

**Example**
```python
project_context.log("Preparing extension steps", level="info")
```

---

### Command Execution

#### `run_command(command_parts: list[str]) -> tuple[str, int]`
Run a local shell command. Returns `(stdout, exit_code)`.

**Example**
```python
out, code = project_context.run_command(["echo", "hello"])
if code == 0:
    project_context.log(out.strip())
```

#### `gcloud(command_parts: list[str]) -> tuple[str, int]`
Run a `gcloud` command using the embedded or system `gcloud` binary.
Returns `(stdout, exit_code)`.

**Example**
```python
out, code = project_context.gcloud(["projects", "list", "--format=value(projectId)"])
if code == 0:
    project_context.log("Projects:\n" + out)
```

---

### Config Access

#### `config_get(key: str, default: Any = None) -> Any`
Fetch a config value using dotted path notation.

**Example**
```python
domain = project_context.config_get("domain", "")
project_context.log(f"Domain: {domain}")
```

#### `config_set(key: str, value: Any) -> None`
Set a config value in memory (does not write to disk).

**Example**
```python
project_context.config_set("gateway.session.stateful", False)
```

#### `config_save() -> None`
Persist config changes to `zarch.yaml`.

**Example**
```python
project_context.config_set("gateway.session.stateful", False)
project_context.config_save()
```

---

### Prompts

These are safe wrappers around Z-Arch’s prompt system.

#### `ask(message: str, default: str | None = None, required: bool = True, validate: Callable | None = None) -> str`
Prompt the user for a string value.

**Example**
```python
name = project_context.ask("What is the service name?", default="session")
```

#### `choice(message: str, choices: list[str], default: str | None = None, sub_prompt: str = "") -> str`
Prompt the user to select a single option.

**Example**
```python
region = project_context.choice("Select region", ["us-east1", "us-west1"], default="us-east1")
```

#### `multichoice(message: str, choices: list[str], default: list[str] | None = None, sub_prompt: str = "(space to toggle, enter to confirm)") -> list[str]`
Prompt the user to select multiple options.

**Example**
```python
features = project_context.multichoice("Enable features", ["cdn", "auth", "logging"])
```

#### `yes_no(message: str, default: bool = True, sub_prompt: str = "") -> bool`
Prompt the user for a yes/no response.

**Example**
```python
confirm = project_context.yes_no("Proceed with cleanup?", default=False)
```

#### `review_and_confirm() -> None`
Render the config and ask the user to confirm. Useful before sensitive operations.

**Example**
```python
project_context.review_and_confirm()
```

---

### GCP Helpers

These helpers wrap common GCP operations and automatically use the project context’s `id` and `region` where applicable.

#### `ensure_service_account(service_account_name: str, **kwargs) -> str`
Ensure a service account exists, creating it if missing.

- `service_account_name` can be either a full email (`name@project.iam.gserviceaccount.com`) or just the short name.
- Optional kwargs:
  - `project_id` (str) override the current project ID
  - `display_name` (str) override the display name

**Example**
```python
sa = project_context.ensure_service_account("zarch-ext")
project_context.log(f"Service account: {sa}")
```

#### `secret_exists(secret_name: str) -> bool`
Check if a Secret Manager secret exists in the current project.

**Example**
```python
if not project_context.secret_exists("my-secret"):
    project_context.log("Secret does not exist")
```

#### `store_secret(secret_name: str, secret_value: str) -> None`
Create or update a Secret Manager secret with a new version.

**Example**
```python
project_context.store_secret("my-secret", "super-secure-token")
```

#### `get_secret(secret_name: str) -> str`
Fetch the latest version of a Secret Manager secret.

**Example**
```python
token = project_context.get_secret("my-secret")
```

#### `get_service_url(service_name: str) -> str`
Fetch the Cloud Run service URL for a named service in the current region.

**Example**
```python
session_url = project_context.get_service_url("session")
project_context.log(f"Session URL: {session_url}")
```

#### `get_env_var(service_name: str, env_var_key: str) -> str`
Read a specific environment variable from a deployed service or function.

**Example**
```python
public_key = project_context.get_env_var("zarch-gateway", "S2S_PUBLIC_KEY")
```

#### `set_env_vars(service_name: str, env_vars: dict[str, str]) -> None`
Set or update environment variables on a deployed service or function.

**Example**
```python
project_context.set_env_vars("session", {"SESSION_TTL": "1209600"})
```

---

### GitHub

#### `github()`
Return an authenticated GitHub client (PyGitHub-style client used internally by Z-Arch).

**Example**
```python
gh = project_context.github()
user = gh.get_user()
project_context.log(f"GitHub user: {user.login}")
```

#### `get_connected_repo() -> tuple[str, str]`
Return the connected repo fullname and branch as `("owner/repo", "branch")`.

**Example**
```python
repo, branch = project_context.get_connected_repo()
project_context.log(f"Connected repo: {repo} ({branch})")
```

---

### Cloudflare

These helpers manage Cloudflare workers and pages as used by Z-Arch.

#### `update_edge_proxy(project_name: str | None = None) -> None`
Update the edge proxy worker for the project.

- If `project_name` is omitted, it is inferred from the connected repo name.

**Example**
```python
project_context.update_edge_proxy()
```

#### `set_edge_proxy_envs(env_vars: dict[str, str], project_name: str | None = None) -> bool`
Set environment variables on the edge proxy worker.

- Returns `True` on success, `False` on failure.

**Example**
```python
ok = project_context.set_edge_proxy_envs({"API_VERSION": "v1"})
if not ok:
    project_context.log("Failed to update edge envs", level="warn")
```

#### `deploy_cf_worker(script_name: str, repo_root_dir: str, repo_full: str | None = None, branch: str | None = None, domain: str | None = None) -> None`
Deploy a Cloudflare Worker from the connected repo.

- `script_name`: Worker script identifier
- `repo_root_dir`: Root path in the repo to deploy
- `repo_full`: Optional `owner/repo` override
- `branch`: Optional branch override
- `domain`: Optional custom domain

**Example**
```python
project_context.deploy_cf_worker(
    script_name="my-worker",
    repo_root_dir="services/edge",
    branch="main",
)
```

#### `set_worker_route(script_name: str, domain: str, route: str = "/api/*") -> None`
Attach a route to a worker script.

**Example**
```python
project_context.set_worker_route("my-worker", "example.com", "/api/*")
```

#### `deploy_cf_pages(domain: str, project_name: str | None = None, repo_full: str | None = None, branch: str | None = None) -> None`
Deploy a Cloudflare Pages project from the connected repo.

**Example**
```python
project_context.deploy_cf_pages("example.com")
```

---

## End-to-End Example

A realistic extension that uses multiple helpers:

```python
from typing import Any, Dict
from zarch.extensions.base import ZArchExtension

class Extension(ZArchExtension):
    def claim(self, extension_name: str, extension_block: Dict[str, Any]) -> bool:
        return extension_block.get("type") == "my-ext"

    def post_service_deploy(self, project_context, extension_configuration: Dict[str, Any]) -> None:
        project_context.log("Post-deploy hook starting")

        # Read config
        domain = project_context.config_get("domain", "")
        if not domain:
            project_context.log("No domain configured", level="warn")
            return

        # Ensure a secret exists
        if not project_context.secret_exists("edge-api-key"):
            project_context.store_secret("edge-api-key", "replace-me")

        # Update edge proxy envs
        project_context.set_edge_proxy_envs({"API_VERSION": "v1"})

        # Deploy pages site
        project_context.deploy_cf_pages(domain)

        project_context.log("Post-deploy hook complete")
```

---

## zarch.yaml
```yaml
    extensions:
      {extension_name}:
        type: "{extension_name}"
        required_roles: []
        config:
          example_key: example_value
```
Add each extention to zarch.yaml or it will not run even if it is installed. The extension block is a dictionary of objects keyed by each extension's name. `type` is the extension's name. Include all GCP IAM roles that are required by the service account that will run the extension in the `required_roles` list. Values in `config:` are available to the extension code at runtime. 

---

## Notes and Best Practices

- Prefer `config_get`/`config_set` over accessing `project_context.config` directly.
- Use `log()` for all extension output to stay consistent with Z-Arch UX.
- Avoid raw shell calls unless absolutely necessary; use provided helpers first.
- Never log secrets or gateway URL suffixes.

If you need additional helpers, consider filing a request rather than importing internal modules directly.

---

## License

Apache License 2.0. See `LICENSE`.
Copyright © 2026 [RAM Cloud Code LLC](https://ramcloudcode.com)
