north-scape/relayboard

Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.

A remix template · 0 remixes · Remix north-scape/relayboard

scripts/check_template.py

"""Check Relayboard's fresh-install contracts, not Dodil/cloud compatibility."""
from pathlib import Path
import re
import yaml

REQUIRED_COLUMNS = {
    'boards': {'id': 'TEXT', 'title': 'TEXT', 'owner_email': 'TEXT',
               'revision': 'INTEGER', 'created_at': 'TIMESTAMP'},
    'memberships': {'id': 'TEXT', 'board_id': 'TEXT', 'email': 'TEXT', 'role': 'TEXT'},
    'cards': {'id': 'TEXT', 'board_id': 'TEXT', 'list_name': 'TEXT', 'title': 'TEXT',
              'assignee': 'TEXT', 'due_date': 'DATE', 'description': 'TEXT', 'labels': 'TEXT',
              'blocked_by': 'TEXT', 'revision': 'INTEGER', 'checklist': 'TEXT', 'updated_at': 'TIMESTAMP'},
    'activity': {'id': 'TEXT', 'board_id': 'TEXT', 'card_id': 'TEXT', 'actor': 'TEXT',
                 'action': 'TEXT', 'changes': 'TEXT', 'event_at': 'TIMESTAMP'},
}

def require(condition, message):
    # Unlike assert, these checks still run under python -O.
    if not condition:
        raise ValueError(message)

def parse_manifest(text):
    value = yaml.safe_load(text)
    if not isinstance(value, dict):
        raise ValueError('manifest must be a YAML mapping')
    return value

def only(items, label):
    require(isinstance(items, list) and len(items) == 1 and isinstance(items[0], dict),
            f'{label}: Relayboard requires exactly one definition')
    return items[0]

def check_schema(text):
    # Deliberately narrow: plain CREATE TABLE statements used by this app.
    # This catches omissions; it does not emulate DataK3 SQL/locking semantics.
    text = re.sub(r'/\*.*?\*/|--[^\n]*', '', text, flags=re.S)
    require(not re.search(r'\b(?:BIGSERIAL|SMALLSERIAL|SERIAL)\b', text, re.I),
            'schema must use application-generated keys, not sequences')
    definitions = re.findall(
        r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\((.*?)\)\s*;',
        text, re.I | re.S)
    tables = {name.lower(): body for name, body in definitions}
    require(len(tables) == len(definitions), 'schema contains duplicate table definitions')
    for name, body in tables.items():
        require(re.search(r'\bPRIMARY\s+KEY\b', body, re.I), f'{name}: table needs a primary key')
    for name, expected in REQUIRED_COLUMNS.items():
        require(name in tables, f'schema is missing table {name}')
        body = tables[name]
        columns = {column.lower(): kind.upper() for column, kind in re.findall(
            r'(?:^|,)\s*(\w+)\s+([A-Za-z]\w*)\b', body, re.I)}
        require(expected.keys() <= columns.keys(),
                f'{name}: missing columns {sorted(expected.keys() - columns.keys())}')
        for column, kind in expected.items():
            require(columns[column] == kind, f'{name}.{column}: expected type {kind}, got {columns[column]}')
        require(re.search(r'\bid\s+TEXT\b[^,]*\bPRIMARY\s+KEY\b', body, re.I)
                or re.search(r'\bPRIMARY\s+KEY\s*\(\s*id\s*\)', body, re.I),
                f'{name}: id must be the application-generated TEXT primary key')
    require(re.search(r'\brevision\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+0\b', tables['boards'], re.I),
            'boards.revision must default to 0 for new boards')
    require(re.search(r"\bchecklist\s+TEXT\s+NOT\s+NULL\s+DEFAULT\s+'\[\]'", tables['cards'], re.I),
            'cards.checklist must default to an empty JSON list')

def validate_template(root):
    root = Path(root)
    for path in ('.dodil/remix.yaml', '.dodil/deploy.yaml', '.github/workflows/ci.yml', 'LICENSE'):
        require((root / path).is_file(), f'missing {path}')
    remix = parse_manifest((root / '.dodil/remix.yaml').read_text())
    deploy = parse_manifest((root / '.dodil/deploy.yaml').read_text())
    parse_manifest((root / '.github/workflows/ci.yml').read_text())
    for label, manifest in [('remix', remix), ('deploy', deploy)]:
        require(type(manifest.get('version')) is int and manifest['version'] == 1,
                f'{label}: expected version 1')
    require(not remix.get('required_secrets'), 'remix must not wait for user-supplied secrets')
    provision = remix.get('provision', {})
    require(isinstance(provision, dict), 'provision must be a mapping')
    bucket = only(provision.get('buckets'), 'buckets')
    pool = only(provision.get('user_pools'), 'user_pools')
    service = only(provision.get('service_accounts'), 'service_accounts')
    for resource, suffix in [(bucket, '-data'), (pool, '-users'), (service, '-app')]:
        require(resource.get('name') == '${{ dodil.repo }}' + suffix,
                f'{suffix}: resource must follow the final remixed repo name')
    for resource in [bucket, pool]:
        require(resource.get('on_conflict', 'fail') == 'fail',
                'bucket/pool conflict policy must be fail; suffix/reuse breaks isolation or bindings')
    require(bucket.get('schema') == 'schema.sql', 'bucket must provision the complete schema.sql')
    require((root / 'schema.sql').is_file(), 'missing schema.sql')
    check_schema((root / 'schema.sql').read_text())
    secrets = service.get('secrets', {})
    require(secrets == {'client_id': 'DODIL_SERVICE_ACCOUNT_ID',
                        'client_secret': 'DODIL_SERVICE_ACCOUNT_SECRET'},
            'generated service-account CI secret names must match the app contract')
    grants = service.get('grants', [])
    require({'role': 'k3.editor', 'bucket': bucket['name']} in grants,
            'service account must edit its provisioned bucket')
    require({'role': 'ignite.model-user'} in grants, 'service account needs Dodil Models access')
    require(deploy.get('create_if_missing') is True, 'fresh deploy must create the app')
    app = only(deploy.get('apps'), 'apps')
    require(app.get('name') == '${{ dodil.repo }}', 'app must use the remixed repo name')
    require(app.get('image') == '{registry}/{org}/{repo}:{sha}', 'image must follow remixed org/repo/commit')
    require(app.get('user_pool') == pool['name'], 'deploy must attach the provisioned pool')
    require(app.get('public_invoke') is True, 'MCP OAuth discovery requires public_invoke: true')
    require(app.get('port') == 8080 and app.get('health', {}).get('path') == '/healthz',
            'deploy port/health must match the container')
    env = app.get('env', {})
    require(isinstance(env, dict), 'app env must be a mapping')
    require(env.get('BUCKET') == bucket['name'], 'BUCKET must match the provisioned bucket')
    require(env.get('DODIL_USER_POOL') == '${{ dodil.org }}/' + pool['name'],
            'OAuth issuer must follow the remixed org and pool')
    for name in secrets.values():
        require(env.get(name) == '${{ secrets.' + name + ' }}',
                f'{name} must reference its generated CI secret, not a literal credential')

if __name__ == '__main__':
    validate_template(Path(__file__).resolve().parents[1])
    print('Local fresh-install contract checks passed; cloud validity is NOT established.')