Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
cb05c66
Add Bloom container runtime
iamh2o May 26, 2026
c343d03
Formalize inf6 deployment readiness
iamh2o May 28, 2026
901d340
Package Bloom TapDB templates in container image
iamh2o May 28, 2026
c704046
Initialize Bloom container runtime context
iamh2o May 28, 2026
fcac343
Copy Bloom UI assets into container image
iamh2o May 28, 2026
e7caca9
Standardize Bloom GUI auth entrypoints
iamh2o May 29, 2026
896569b
Restore Bloom prefix taxonomy templates
iamh2o Jun 1, 2026
3bfa5b5
Repair Bloom EUID actions and retire queue GUI
iamh2o Jun 2, 2026
a2b6d57
Release Bloom 6.0.0 TapDB UI updates
iamh2o Jun 4, 2026
e8bafce
Merge PR #244 into jem-dev
iamh2o Jun 4, 2026
d7e3ee6
Fix Bloom 6.0 lockfile
iamh2o Jun 4, 2026
2e53e0c
Add AI agent reads and broker themes
iamh2o Jun 6, 2026
bab1c3c
Mark AI access gap note superseded
iamh2o Jun 6, 2026
f684f27
Keep TapDB core prefixes out of Bloom claims
iamh2o Jun 6, 2026
1c2807c
Honor disabled Bloom TapDB metrics config
iamh2o Jun 7, 2026
15acf9a
Honor explicit Bloom metrics disable config
iamh2o Jun 7, 2026
ad6398a
Add Bloom anomaly TapDB template
iamh2o Jun 7, 2026
e7b58a5
Verify broker preference writes by readback
iamh2o Jun 7, 2026
dffdac3
Add common access logging fields
iamh2o Jun 8, 2026
9aa0cc3
Emit access logs to container stdout
iamh2o Jun 8, 2026
90cc7d1
Expose Bloom TapDB GUI surfaces
iamh2o Jun 10, 2026
bb1d904
Release Bloom TapDB 9 beta updates
iamh2o Jun 15, 2026
18cb8be
Fix TapDB core prefix ownership seeding
iamh2o Jun 15, 2026
1ac6b8d
Refresh TapDB core templates during Bloom seed
iamh2o Jun 15, 2026
c37fe2d
Pin TapDB 9.0.1 Git tag
iamh2o Jun 15, 2026
b941e4c
Install git for Git-pinned TapDB builds
iamh2o Jun 15, 2026
eb9e898
Scope Docker SCM version to Bloom package
iamh2o Jun 15, 2026
5893f41
Unset global SCM version during Bloom image sync
iamh2o Jun 15, 2026
74706ca
Pin TapDB 9.0.2
iamh2o Jun 15, 2026
7ddb36b
Pin TapDB 9.0.3
iamh2o Jun 15, 2026
fb8a332
Pin Bloom to TapDB 9.0.4
iamh2o Jun 15, 2026
7296ee7
Use TapDB 9 actor users for Bloom roles
iamh2o Jun 16, 2026
3bb128b
Add Bloom v0 order specimen references
iamh2o Jun 16, 2026
a636d62
Preserve Atlas order refs in beta material context
iamh2o Jun 16, 2026
34c684b
Resolve Atlas fulfillment slots in Bloom beta lineage
iamh2o Jun 16, 2026
554b6ee
Release Bloom v0 graph closeout
iamh2o Jun 16, 2026
d03a3c6
Add Bloom lab action flow
iamh2o Jun 18, 2026
62d060c
Refresh README current-state documentation
iamh2o Jun 18, 2026
8b64cad
Add lab-action spreadsheet upload flows
iamh2o Jun 18, 2026
0d57f0a
Release 7.0.17
iamh2o Jun 18, 2026
5861cb3
Complete Bloom lab-action live proof support
iamh2o Jun 18, 2026
56d2961
Return plate identifiers from lab-action data attach
iamh2o Jun 18, 2026
2d330b9
Record live lab action E2E proof
iamh2o Jun 19, 2026
94f0898
Fix TapDB semantic template categories
iamh2o Jun 19, 2026
af6b81b
Add Bloom container action API and GUI
iamh2o Jun 19, 2026
b096f14
Pin TapDB 9.0.9
iamh2o Jun 19, 2026
e9961c9
Ignore TapDB generic set prefix in Bloom seeding
iamh2o Jun 19, 2026
856f921
Release Bloom 8.0.0 with TapDB 9.0.10
iamh2o Jul 2, 2026
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
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Shell Session Defaults

- Default to an interactive shell for shell work. On this Mac, use the user's default shell unless the user explicitly asks for another shell.
- For AWS EC2, ParallelCluster, and other remote Linux hosts, default to an interactive `bash` login shell as `ubuntu`. Do not use `root` unless the user explicitly grants permission for that specific work; use targeted `sudo` from `ubuntu` when escalation is required.
- For Daylily/DayOA/DAY-EC headnode workflow work, use an interactive `ubuntu` tmux/login-shell pane for controllers and workflow commands. Run setup as separate commands in that pane (`source dyoainit`, then `dy-a ...`, then `dy-r ...`) so aliases/functions are defined before use.
- SSM Run Command is for simple inspection or for writing helper scripts through the supported helpers. Do not launch workflow controllers or rely on `dy-*` aliases from non-interactive SSM scripts.

# Bloom CLI Policy

## Session Setup
Expand Down Expand Up @@ -57,10 +64,26 @@ source ./activate <deploy-name>
- Service-host certs use DNS-01 renewal; do not depend on HTTP-01 public reachability for Bloom service hosts.
- Future dev, test, and stage deployments must use their own approved-source lists, credentials, certificates, TapDB schemas, and tenant data, separate from production.

## Version Tags

- Use non-v semver tags for package releases, e.g. `2.0.19` or `5.0.21`, not `v2.0.19`.
- Commit first, then tag the exact clean release commit.
- Use annotated tags for release provenance: `git tag -a 2.0.19 -m "Release 2.0.19"`.
- Lightweight tags are acceptable only for scratch/internal marks, not package releases.
- Do not move or overwrite pushed version tags. If a pushed tag is wrong, cut the next patch version.
- If signing is configured and expected, use signed annotated tags: `git tag -s 2.0.19 -m "Release 2.0.19"`.
- Verify tag type with `git cat-file -t 2.0.18`; `tag` means annotated and `commit` means lightweight.

## Bloom Examples

- Start with `source ./activate <deploy-name>`
- Use `bloom db build`
- Use `bloom db seed`
- Use `bloom server start --port 8912`
- Use `tapdb ...` and `daycog ...` only where Bloom docs or Bloom CLI explicitly delegate to them
# DEPLOYED SERVICE GUI AUTH EVIDENCE

- For deployed service acceptance, API health checks and HTTP `302` redirects are not sufficient. Also use Playwright or the bundled Playwright CLI workflow to open each deployed GUI login page, snapshot the page, click through to the Google OAuth option, and capture screenshots.
- Store screenshots and records under `output/playwright/` or the deployment ledger evidence directory. Record service name, deploy name, URL, timestamp, screenshot paths, and status as `confirmed_success` or `confirmed_fail`.
- If the Google OAuth click reaches an expected external blocker, such as `redirect_uri_mismatch`, record the exact callback URI and mark the row blocked instead of treating the service as healthy.
- Debug and fix failures that are in repository/deployment scope. If the failure requires external configuration or human credentials, annotate the reason precisely. Do not enter real user credentials unless the user explicitly authorizes that in the current turn.
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ ARG PYTHON_VERSION=3.12
FROM ghcr.io/astral-sh/uv:0.5.30-python${PYTHON_VERSION}-bookworm-slim AS builder

ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} \
ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_BLOOM_LIMS=${SETUPTOOLS_SCM_PRETEND_VERSION} \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& apt-get install -y --no-install-recommends build-essential ca-certificates git libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
RUN unset SETUPTOOLS_SCM_PRETEND_VERSION && uv sync --frozen --no-dev --no-install-project

COPY config ./config
COPY auth ./auth
COPY bloom_lims ./bloom_lims
COPY main.py ./main.py
COPY static ./static
COPY templates ./templates
RUN uv sync --frozen --no-dev
RUN unset SETUPTOOLS_SCM_PRETEND_VERSION && uv sync --frozen --no-dev

FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime

Expand Down
445 changes: 91 additions & 354 deletions README.md

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions bloom_lims/ai_agent_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Validation for Kahlo-issued read-only AI-agent tokens."""

from __future__ import annotations

import hashlib
import json
import os
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from fastapi import Request

AI_AGENT_TOKEN_PREFIX = "kahlo_ai_"


@dataclass(frozen=True)
class EndpointSpec:
endpoint_id: str
method: str
path_template: str


@dataclass(frozen=True)
class ValidatedAgentAccess:
token_id: str
agent_id: str
issued_by_email: str
endpoint_id: str
expires_at: str


class AgentTokenError(RuntimeError):
def __init__(self, detail: str, *, status_code: int = 401) -> None:
super().__init__(detail)
self.detail = detail
self.status_code = status_code


ENDPOINT_CATALOG: tuple[EndpointSpec, ...] = (
EndpointSpec("bloom.search.query", "POST", "/api/v1/search/v2/query"),
EndpointSpec("bloom.objects.list", "GET", "/api/v1/objects"),
EndpointSpec("bloom.objects.detail", "GET", "/api/v1/objects/{euid}"),
EndpointSpec("bloom.containers.list", "GET", "/api/v1/containers"),
EndpointSpec("bloom.containers.detail", "GET", "/api/v1/containers/{euid}"),
EndpointSpec("bloom.content.list", "GET", "/api/v1/content"),
EndpointSpec("bloom.content.detail", "GET", "/api/v1/content/{euid}"),
EndpointSpec("bloom.graph.detail", "GET", "/api/v1/graph/{euid}"),
)


def is_ai_agent_token(token: str) -> bool:
return str(token or "").startswith(AI_AGENT_TOKEN_PREFIX)


def _truthy_env(name: str) -> bool:
return str(os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"}


def _template_matches(template: str, path: str) -> bool:
template_parts = [part for part in template.strip("/").split("/") if part]
path_parts = [part for part in path.strip("/").split("/") if part]
if len(template_parts) != len(path_parts):
return False
for template_part, path_part in zip(template_parts, path_parts, strict=True):
if template_part.startswith("{") and template_part.endswith("}"):
if not path_part:
return False
continue
if template_part != path_part:
return False
return True


def _endpoint_id_for_request(method: str, path: str) -> str:
resolved_method = str(method or "").upper()
resolved_path = str(path or "").split("?", 1)[0].rstrip("/") or "/"
for spec in ENDPOINT_CATALOG:
if spec.method == resolved_method and _template_matches(
spec.path_template, resolved_path
):
return spec.endpoint_id
raise AgentTokenError(
"AI-agent token is not authorized for this endpoint", status_code=403
)


def _parse_expiry(value: str) -> datetime:
raw = str(value or "").strip()
if raw.endswith("Z"):
raw = f"{raw[:-1]}+00:00"
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)


def _load_grants(path: Path) -> list[dict[str, Any]]:
if not path.is_absolute():
raise AgentTokenError(
"AI-agent grant store path must be absolute", status_code=500
)
if not path.exists():
raise AgentTokenError("AI-agent grant store is missing", status_code=500)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise AgentTokenError(
"AI-agent grant store is malformed", status_code=500
) from exc
if not isinstance(payload, dict) or not isinstance(payload.get("tokens"), list):
raise AgentTokenError(
"AI-agent grant store must contain a tokens list", status_code=500
)
return [record for record in payload["tokens"] if isinstance(record, dict)]


def validate_ai_agent_request(request: Request, token: str) -> ValidatedAgentAccess:
if not is_ai_agent_token(token):
raise AgentTokenError("Not an AI-agent token")
if not _truthy_env("LSMC_AI_AGENT_ACCESS_ENABLED"):
raise AgentTokenError("AI-agent token access is not enabled", status_code=503)
raw_path = str(os.environ.get("LSMC_AI_AGENT_GRANTS_PATH") or "").strip()
if not raw_path:
raise AgentTokenError("LSMC_AI_AGENT_GRANTS_PATH is required", status_code=500)

endpoint_id = _endpoint_id_for_request(request.method, request.url.path)
token_digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
for record in _load_grants(Path(raw_path)):
if not secrets.compare_digest(
str(record.get("token_hash") or ""), token_digest
):
continue
if record.get("revoked_at"):
raise AgentTokenError("AI-agent token has been revoked")
expires_at = _parse_expiry(str(record.get("expires_at") or ""))
if datetime.now(UTC) >= expires_at:
raise AgentTokenError("AI-agent token has expired")
endpoint_ids = [str(item) for item in record.get("endpoint_ids") or []]
if endpoint_id not in endpoint_ids:
raise AgentTokenError(
"AI-agent token is not authorized for this endpoint", status_code=403
)
validated = ValidatedAgentAccess(
token_id=str(record.get("token_id") or ""),
agent_id=str(record.get("agent_id") or ""),
issued_by_email=str(record.get("issued_by_email") or ""),
endpoint_id=endpoint_id,
expires_at=str(record.get("expires_at") or ""),
)
request.state.auth_mode = "ai_agent_token"
request.state.agent_id = validated.agent_id
request.state.authorized_by_email = validated.issued_by_email
request.state.agent_token_id = validated.token_id
request.state.agent_endpoint_id = validated.endpoint_id
return validated
raise AgentTokenError("AI-agent token is unknown")
2 changes: 1 addition & 1 deletion bloom_lims/anomalies.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from bloom_lims.config import get_settings
from bloom_lims.observability import ProjectionMetadata

ANOMALY_TEMPLATE_CODE = "bloom/ops/anomaly-record/1.0/"
ANOMALY_TEMPLATE_CODE = "ops/anomaly-record/generic/1.0/"
ANOMALY_PREFIX = "BAN"


Expand Down
8 changes: 8 additions & 0 deletions bloom_lims/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
from .admin_auth import router as admin_auth_router
from .async_tasks import router as async_tasks_router
from .atlas_bridge import router as atlas_bridge_router
from .auth import preferences_router
from .auth import router as auth_router
from .batch import router as batch_router
from .beta_lab import router as beta_lab_router
from .container_actions import router as container_actions_router
from .containers import router as containers_router
from .content import router as content_router
from .equipment import router as equipment_router
from .execution_queue import router as execution_queue_router
from .external_specimens import router as external_specimens_router
from .graph import router as graph_router
from .lab_actions import router as lab_actions_router
from .lineages import router as lineages_router
from .object_creation import router as object_creation_router
from .objects import router as objects_router
Expand All @@ -41,7 +44,9 @@
# Include sub-routers
router.include_router(objects_router)
router.include_router(auth_router)
router.include_router(preferences_router)
router.include_router(containers_router)
router.include_router(container_actions_router)
router.include_router(content_router)
router.include_router(equipment_router)
router.include_router(execution_queue_router)
Expand All @@ -60,6 +65,7 @@
router.include_router(atlas_bridge_router)
router.include_router(beta_lab_router)
router.include_router(graph_router)
router.include_router(lab_actions_router)


@router.get("/")
Expand All @@ -73,6 +79,7 @@ async def api_v1_info():
"objects": "/api/v1/objects",
"auth": "/api/v1/auth",
"containers": "/api/v1/containers",
"container_actions": "/api/v1/container-actions",
"content": "/api/v1/content",
"equipment": "/api/v1/equipment",
"execution": "/api/v1/execution",
Expand All @@ -90,5 +97,6 @@ async def api_v1_info():
"external_atlas": "/api/v1/external/atlas",
"external_atlas_beta": "/api/v1/external/atlas/beta",
"graph": "/api/v1/graph",
"lab_actions": "/api/v1/lab-actions",
},
}
84 changes: 83 additions & 1 deletion bloom_lims/api/v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,42 @@
"""

import logging
import os
from urllib.parse import quote

from fastapi import APIRouter, Depends
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request

from .dependencies import APIUser, require_api_auth

logger = logging.getLogger(__name__)
THEME_NAMES = {"original", "light", "dark", "ssf", "viridis", "viridis-dark"}

router = APIRouter(prefix="/auth", tags=["Authentication"])
preferences_router = APIRouter(tags=["Preferences"])


def _broker_preferences_contract(email: str) -> tuple[str, dict[str, str]]:
raw_url = str(os.environ.get("LSMC_AUTH_BROKER_USER_PREFERENCES_URL") or "").strip()
token = str(os.environ.get("LSMC_AUTH_BROKER_SERVICE_TOKEN") or "").strip()
service_id = str(os.environ.get("LSMC_AUTH_BROKER_SERVICE_ID") or "bloom").strip()
if not raw_url:
raise HTTPException(
status_code=503, detail="Broker user preferences URL is not configured"
)
if not token:
raise HTTPException(
status_code=503, detail="Broker service token is not configured"
)
if "{email}" not in raw_url:
raise HTTPException(
status_code=503,
detail="Broker user preferences URL must include {email}",
)
return raw_url.replace("{email}", quote(email, safe="")), {
"Authorization": f"Bearer {token}",
"X-LSMC-Service-ID": service_id,
}


@router.get("/me")
Expand All @@ -31,6 +59,60 @@ async def get_current_user(user: APIUser = Depends(require_api_auth)):
}


@preferences_router.get("/me/preferences")
async def current_user_preferences(user: APIUser = Depends(require_api_auth)):
if not user.email:
raise HTTPException(
status_code=400, detail="Authenticated user email is required"
)
url, headers = _broker_preferences_contract(user.email)
with httpx.Client(timeout=5.0) as client:
response = client.get(url, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.text)
return response.json()


@preferences_router.put("/me/preferences")
async def update_current_user_preferences(
request: Request,
user: APIUser = Depends(require_api_auth),
):
if not user.email:
raise HTTPException(
status_code=400, detail="Authenticated user email is required"
)
payload = await request.json()
theme = str(payload.get("theme") or "").strip()
if theme and theme not in THEME_NAMES:
raise HTTPException(status_code=400, detail="Unknown theme")
service_themes = payload.get("service_themes")
if service_themes is not None:
if not isinstance(service_themes, dict):
raise HTTPException(
status_code=400, detail="service_themes must be an object"
)
for service_theme in service_themes.values():
if (
service_theme is not None
and str(service_theme).strip() not in THEME_NAMES
):
raise HTTPException(status_code=400, detail="Unknown theme")
forward_payload = {}
if "theme" in payload:
forward_payload["theme"] = theme or None
if service_themes is not None:
forward_payload["service_themes"] = service_themes
url, headers = _broker_preferences_contract(user.email)
with httpx.Client(timeout=5.0) as client:
response = client.put(url, headers=headers, json=forward_payload)
if response.status_code < 400:
response = client.get(url, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.text)
return response.json()


@router.post("/logout")
async def logout():
"""Logout current user.
Expand Down
Loading
Loading