"""Tech Tools plugin. A section for small technician utilities. MOST tools here run entirely in the browser - no API, no tables, no state - which is deliberate: these are the things someone reaches for at a bench with no network guarantee, and an air-gapped site gets them for free. SECURE SEND IS THE EXCEPTION, and it is why this plugin now has a blueprint, a table and a migration chain where it once had none. A link that expires and dies on first read cannot be browser-only: something has to hold the ciphertext until it is claimed, and forget it afterwards. The secret itself is still encrypted in the browser - the key travels in the URL fragment and never reaches this server - so what is stored is ciphertext nobody on this side can read. The plugin also exists so the section can carry a nav entry and be included in or left out of a per-site build (ADR-013). """ import logging from typing import List, Optional, Type from flask import Blueprint, Flask from shopdb.plugins.base import BasePlugin, PluginMeta from .models import SecureSecret from .api import tools_bp logger = logging.getLogger(__name__) class ToolsPlugin(BasePlugin): """Registers the Tech Tools section and the one tool that needs a server.""" @property def meta(self) -> PluginMeta: return PluginMeta( name='tools', version='1.0.0', description=('Tech Tools: technician utilities, mostly browser-side ' '(barcode/QR labels) plus secure send for handing ' 'somebody a credential by one-view link'), author='ShopDB Team', dependencies=[], core_version='>=0.16.0,<1.0.0', api_prefix='/api/tools', ) def get_blueprint(self) -> Optional[Blueprint]: # Secure send only. Every other tool is still browser-side. return tools_bp def get_models(self) -> List[Type]: return [SecureSecret] def get_permissions(self) -> List: """Who may CREATE a secret, when guests are not allowed. The default access model: signed in, and holding this. Reading needs no permission at all - the recipient usually has no account here and the unguessable token is the authorisation. A site that turns on securesend_allow_guests bypasses this for people with no account; it still applies to anyone who IS signed in, so a member without it cannot create one while a guest can. That is deliberate: opening the door to guests is a decision about the door, not about revoking what a role does or does not hold. """ return [ # Reaching the tools page at all. The tools themselves run in the # browser and touch no data, so this is about whether the entry # appears - which is exactly the question a site answers when it # decides whether people without an account may use them. ('tools.view', 'Use the browser tools (codes, conversions)', 'tools'), ('tools.secrets', 'Create secure send links', 'tools'), ] def get_guest_grantable_permissions(self) -> List[str]: """Creating a secure send link is meant to work without an account. Somebody standing at a bench needs to send a password to a vendor. The recipient never had an account, and often neither does the sender - the unguessable token is the authorisation, and the link is one-view. A guest-created link records no creator. The audit trail says when and from where, and never who. That is the cost, and it is why this is the only write in the product offered to guests at all. """ return ['tools.secrets'] def get_settings_defaults(self) -> List[dict]: """The one switch this plugin owns. Public so the create page can read it BEFORE login and show a guest the form rather than a sign-in wall. It says nothing sensitive: whether the door is open is visible to anyone who tries the door. """ return [ { 'key': 'securesend_allow_guests', 'value': 'false', 'valuetype': 'boolean', 'category': 'tools', 'public': True, 'description': ('Let people with no account create secure send ' 'links. Off by default: a guest secret has no ' 'creator, so the audit trail can say when and ' 'from where but never who. Receiving a secret ' 'never needs an account either way.'), }, ] def get_settings_cards(self) -> List[dict]: """A page for the one switch this plugin owns (ADR-010). get_settings_defaults SEEDS the row; this is what gives somebody a place to change it. Declaring only the first left the setting in the database with no way to reach it, which is indistinguishable from the feature not existing. """ return [ { 'group': 'Tools', 'to': '/settings/secure-send', # From the settings iconMap; an unknown key falls back to a # puzzle piece, which reads as "nobody chose this". 'icon': 'wrench', 'title': 'Secure Send', 'description': 'Whether people without an account can create ' 'one-view password links', 'position': 60, }, ] def get_navigation_items(self) -> List[dict]: return [ { 'name': 'Tech Tools', 'icon': 'wrench', 'route': '/tools', 'position': 47, 'section': 'fleet', # Either right reaches the page: somebody granted only Secure # Send still needs the entry that leads to it. 'permission': ['tools.view', 'tools.secrets'], }, ] def on_install(self, app: Flask) -> None: logger.info('Tools plugin installed')