Move secure send into Tech Tools, where somebody would look for it
9845867gave it its own plugin because Tech Tools is browser-only and this needs storage. That was the right reading of the code and the wrong answer for the person using it: Tech Tools is where you go for a utility, and a tool nobody can find is a tool nobody uses. So Tech Tools now has a blueprint, a table and a migration chain where it had none. Its docstring and manifest say why, because "every tool here runs in the browser" was load-bearing documentation and is no longer true. WHAT MOVED /api/securesend -> /api/tools/secrets securesend.create -> tools.secrets securesend0001baseline -> tools0001securesend PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal profile and the api-inventory surface all follow it. THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted get_blueprint() is None, which is the sort of guard that gets removed when it becomes inconvenient. It now asserts the blueprint serves EXACTLY the three secret routes, with their methods - so a future tool that quietly grows an endpoint or a table has to edit this test, and therefore make the decision on purpose rather than by drift. Asserting methods rather than paths also pins the detail this design turns on: redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build previews, and a preview that could redeem would consume a one-view secret before its recipient opened the link. Previously that was a comment; now it fails a test. UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin whose directory has been deleted cannot be removed by the command that manages plugins - the registry entry had to be edited by hand. Worth fixing separately; it is the same registry-versus-code divergence the device work hit from the other side. MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row dropped first, because the relocated chain has no stamp and tries to create a table that exists. Only relevant to anyone who installed securesend during the hour it existed - it did reach the publication repo in4242028- and the fix is DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before running plugin upgrade-all. 1829 tests green. Still backend only: there are no pages yet, so nothing here is reachable by a person.
This commit is contained in:
5
plugins/tools/api/__init__.py
Normal file
5
plugins/tools/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Tech Tools API package."""
|
||||
|
||||
from .routes import tools_bp
|
||||
|
||||
__all__ = ['tools_bp']
|
||||
176
plugins/tools/api/routes.py
Normal file
176
plugins/tools/api/routes.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Secure send: create a one-view link for a credential, and redeem it.
|
||||
|
||||
Lives in Tech Tools because that is where somebody looks for a utility. It is
|
||||
the one tool here that is NOT purely browser-side: a link that expires and dies
|
||||
on first read needs somewhere to keep the ciphertext until then.
|
||||
|
||||
TWO DIFFERENT DOORS, on purpose.
|
||||
|
||||
CREATE needs a login and a permission. Anonymous creation would be an
|
||||
untraceable way to move data out of a system that otherwise records who did
|
||||
what, and the creator is the only person these rows can ever name.
|
||||
|
||||
READ needs neither, because the recipient usually has no account here and the
|
||||
link is normally handed over in a call or a chat. The unguessable token IS the
|
||||
authorisation - which is why it is 128 bits of randomness and why the row is
|
||||
spent on the first successful read.
|
||||
|
||||
WHAT KEEPS THIS FROM BECOMING A DOCUMENT TRANSFER. The key never reaches the
|
||||
server, so the server cannot look at what it holds and cannot tell a password
|
||||
from a page of a drawing. The only lever it has is SIZE, and MAX_CIPHERTEXT is
|
||||
therefore not a tuning knob - it is the whole enforcement. Everything else (line
|
||||
counts, file signatures, refusing a pasted file) runs in the browser, guides an
|
||||
honest person, and is bypassable by anyone willing to POST directly.
|
||||
"""
|
||||
|
||||
import secrets as pysecrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import SecureSecret
|
||||
|
||||
tools_bp = Blueprint('tools', __name__)
|
||||
|
||||
# Base64 of an encrypted credential. A password is well under a hundred
|
||||
# characters; this is generous for a connection string and useless for a
|
||||
# document. See the module docstring: this limit is the only server-side control
|
||||
# that a zero-knowledge design leaves available.
|
||||
MAX_CIPHERTEXT = 4096
|
||||
|
||||
# Hours. The default is short because the normal case is "read it while we are
|
||||
# on this call"; the maximum exists so nothing lingers for a week.
|
||||
DEFAULT_EXPIRY_HOURS = 24
|
||||
MAX_EXPIRY_HOURS = 168
|
||||
|
||||
MAX_VIEWS = 5
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@tools_bp.route('/secrets', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('tools.secrets')
|
||||
def create_secret():
|
||||
"""Store an already-encrypted secret and return its token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
ciphertext = (data.get('ciphertext') or '').strip()
|
||||
iv = (data.get('iv') or '').strip()
|
||||
|
||||
if not ciphertext or not iv:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'ciphertext and iv are required')
|
||||
|
||||
if len(ciphertext) > MAX_CIPHERTEXT:
|
||||
# Say what the limit is FOR, not just that it exists. Somebody hitting
|
||||
# it is usually trying to send a file, and the answer to that is a
|
||||
# different tool rather than a bigger number here.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Secret is too large ({} of {} characters). This is for passwords '
|
||||
'and short text, not documents - the server cannot read what it '
|
||||
'holds, so size is the only limit it can enforce.'.format(
|
||||
len(ciphertext), MAX_CIPHERTEXT))
|
||||
|
||||
# `is None`, not `or`: an explicit 0 is falsy, so `or DEFAULT` silently
|
||||
# turned "expire immediately" into "expire in a day" instead of refusing it.
|
||||
# A caller who sends a number should be told when it is not allowed, not
|
||||
# quietly given a different one.
|
||||
rawhours = data.get('expiryhours')
|
||||
rawviews = data.get('views')
|
||||
try:
|
||||
hours = int(DEFAULT_EXPIRY_HOURS if rawhours is None else rawhours)
|
||||
views = int(1 if rawviews is None else rawviews)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'expiryhours and views must be whole numbers')
|
||||
|
||||
if not 1 <= hours <= MAX_EXPIRY_HOURS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'expiryhours must be between 1 and {}'.format(MAX_EXPIRY_HOURS))
|
||||
if not 1 <= views <= MAX_VIEWS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'views must be between 1 and {}'.format(MAX_VIEWS))
|
||||
|
||||
try:
|
||||
userid = int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
userid = None
|
||||
|
||||
secret = SecureSecret(
|
||||
token=pysecrets.token_hex(16), # 128 bits; the link is the credential
|
||||
ciphertext=ciphertext,
|
||||
iv=iv,
|
||||
expiresat=_utcnow() + timedelta(hours=hours),
|
||||
viewsremaining=views,
|
||||
createdbyuserid=userid,
|
||||
)
|
||||
db.session.add(secret)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(secret.to_dict(), http_code=201)
|
||||
|
||||
|
||||
@tools_bp.route('/secrets/<token>', methods=['POST'])
|
||||
def redeem_secret(token):
|
||||
"""Hand over the ciphertext once, then spend the view.
|
||||
|
||||
POST rather than GET on purpose. A GET is prefetched by link previews in
|
||||
chat and mail clients, and a one-view secret consumed by a preview window is
|
||||
a secret the recipient never gets to see.
|
||||
|
||||
A missing, expired and spent secret all answer the SAME way. Distinguishing
|
||||
them would confirm to anyone holding a guessed token whether it ever named
|
||||
anything.
|
||||
"""
|
||||
gone = error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'This link is not valid. It may have been opened already, or expired.',
|
||||
http_code=404)
|
||||
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
if secret is None:
|
||||
return gone
|
||||
|
||||
now = _utcnow()
|
||||
if secret.is_expired(now) or secret.is_spent():
|
||||
# Tidy up on the way past rather than waiting for a sweeper: a spent
|
||||
# secret has no reason to keep its ciphertext.
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
return gone
|
||||
|
||||
payload = {'ciphertext': secret.ciphertext, 'iv': secret.iv}
|
||||
|
||||
secret.viewsremaining -= 1
|
||||
secret.viewcount += 1
|
||||
secret.lastviewedat = now
|
||||
if secret.viewsremaining <= 0:
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@tools_bp.route('/secrets/<token>', methods=['GET'])
|
||||
def peek_secret(token):
|
||||
"""Whether a link is still live, WITHOUT spending it.
|
||||
|
||||
The read page asks this first so it can render "this secret is waiting, and
|
||||
opening it will destroy it" rather than consuming the only view to find out
|
||||
there was something there.
|
||||
"""
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
live = bool(secret and not secret.is_expired() and not secret.is_spent())
|
||||
return success_response({'available': live})
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)",
|
||||
"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",
|
||||
"provides": {
|
||||
"features": ["code_generator"]
|
||||
}
|
||||
"features": [
|
||||
"code_generator",
|
||||
"secure_send"
|
||||
]
|
||||
},
|
||||
"api_prefix": "/api/tools"
|
||||
}
|
||||
|
||||
16
plugins/tools/migrations/env.py
Normal file
16
plugins/tools/migrations/env.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Alembic environment for the tools plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_tools. See ADR-008 for the ownership
|
||||
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
|
||||
is NEW: its 0001 baseline really CREATES its tables, because the core chain
|
||||
never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'tools'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/tools/migrations/script.py.mako
Normal file
24
plugins/tools/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""tools plugin baseline: the secure send table.
|
||||
|
||||
Creates the one table this plugin owns. Tech Tools was schemaless until secure
|
||||
send arrived: a link that expires and dies on first read has to keep the
|
||||
ciphertext somewhere until it is claimed. Not an asset extension: a shared
|
||||
credential is a short-lived event rather than a thing the site inventories, so
|
||||
there is no assetid and no asset type.
|
||||
|
||||
Explicit Alembic ops rather than the create_plugin_tables helper, matching the
|
||||
backups baseline: the helper builds a per-plugin MetaData filtered to this
|
||||
plugin's tables, so the foreign key to the core users table cannot resolve at
|
||||
CreateTable-compile time.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'tools0001securesend'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'securesendsecrets',
|
||||
sa.Column('secretid', sa.Integer(), nullable=False),
|
||||
# 128 bits of randomness as hex. With no login on the read side this is
|
||||
# the credential, so it must never be a sequence.
|
||||
sa.Column('token', sa.String(length=64), nullable=False),
|
||||
sa.Column('ciphertext', sa.Text(), nullable=False),
|
||||
sa.Column('iv', sa.String(length=64), nullable=False),
|
||||
sa.Column('expiresat', sa.DateTime(), nullable=False),
|
||||
sa.Column('viewsremaining', sa.Integer(), nullable=False,
|
||||
server_default='1'),
|
||||
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
|
||||
sa.Column('lastviewedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('viewcount', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('secretid'),
|
||||
)
|
||||
# Unique, because the token is looked up on every read and two rows sharing
|
||||
# one would make a link ambiguous.
|
||||
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
# Expiry sweeps scan on this.
|
||||
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ixsecuresendsecretscreatedby', table_name='securesendsecrets')
|
||||
op.drop_index('ixsecuresendsecretsexpiresat', table_name='securesendsecrets')
|
||||
op.drop_index('ixsecuresendsecretstoken', table_name='securesendsecrets')
|
||||
op.drop_table('securesendsecrets')
|
||||
10
plugins/tools/models/__init__.py
Normal file
10
plugins/tools/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Tech Tools models.
|
||||
|
||||
Most tools run entirely in the browser and own nothing. Secure send is the
|
||||
exception: handing somebody a credential needs storage, an expiry and a view
|
||||
counter, so this plugin does carry one table.
|
||||
"""
|
||||
|
||||
from .securesend import SecureSecret
|
||||
|
||||
__all__ = ['SecureSecret']
|
||||
77
plugins/tools/models/securesend.py
Normal file
77
plugins/tools/models/securesend.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""A secret handed over by link, encrypted before it ever reaches here.
|
||||
|
||||
NOT AN ASSET, so this is a standalone table rather than an extension keyed by
|
||||
assetid: a shared credential is an event with a short life, not a thing the site
|
||||
owns and inventories.
|
||||
|
||||
WHAT THIS TABLE CAN AND CANNOT SEE. The AES key lives in the URL fragment, which
|
||||
browsers never send to a server, so what is stored is ciphertext and an IV and
|
||||
nothing else. Compromising this table yields no secrets. It also means the
|
||||
server cannot inspect what it is holding - which is the right trade for a
|
||||
credential, and the reason the SIZE limit in the routes carries the whole weight
|
||||
of "this is not a document transfer".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class SecureSecret(BaseModel):
|
||||
"""One shared secret: opaque id, ciphertext, and the rules for its life."""
|
||||
|
||||
__tablename__ = 'securesendsecrets'
|
||||
|
||||
secretid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The unguessable half of the link. With no login on the read side this IS
|
||||
# the credential, so it is 128 bits of randomness rendered as hex - never a
|
||||
# sequence, which would be enumerable by anyone who received one link.
|
||||
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
|
||||
# Base64 of the AES-GCM ciphertext, and the IV it was sealed with. Opaque
|
||||
# here by design.
|
||||
ciphertext = db.Column(db.Text, nullable=False)
|
||||
iv = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Both limits apply, whichever comes first.
|
||||
expiresat = db.Column(db.DateTime, nullable=False, index=True)
|
||||
viewsremaining = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
# Who created it. The reader is anonymous by design, so this is the only
|
||||
# person the row can name - and the reason creation requires a login.
|
||||
createdbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Recorded for the audit trail; the content of what was read is not
|
||||
# recoverable, but that it was read, and when, is.
|
||||
lastviewedat = db.Column(db.DateTime, nullable=True)
|
||||
viewcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
def is_expired(self, now=None):
|
||||
return (now or _utcnow()) >= self.expiresat
|
||||
|
||||
def is_spent(self):
|
||||
return self.viewsremaining <= 0
|
||||
|
||||
def to_dict(self):
|
||||
"""NEVER includes the ciphertext.
|
||||
|
||||
This is the shape the CREATOR sees back - a receipt. Handing the payload
|
||||
back on create would put the secret in a second place for no reason.
|
||||
"""
|
||||
return {
|
||||
'token': self.token,
|
||||
'expiresat': self.expiresat.isoformat() if self.expiresat else None,
|
||||
'viewsremaining': self.viewsremaining,
|
||||
'viewcount': self.viewcount,
|
||||
'createddate': self.createddate.isoformat() if self.createddate else None,
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Tech Tools plugin.
|
||||
|
||||
A section for small technician utilities. Every tool here runs entirely in the
|
||||
browser: no API, no tables, no state. That 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.
|
||||
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.
|
||||
|
||||
The plugin exists so the section can carry a nav entry and be included in or
|
||||
left out of a per-site build (ADR-013). The tools themselves are frontend-only,
|
||||
which is why get_blueprint and get_models are both empty.
|
||||
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
|
||||
@@ -17,30 +23,51 @@ 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. All tools are client-side."""
|
||||
"""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: browser-side technician utilities',
|
||||
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]:
|
||||
# No API surface. The tools never leave the browser.
|
||||
return None
|
||||
# Secure send only. Every other tool is still browser-side.
|
||||
return tools_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
# No tables. Nothing here is persisted.
|
||||
return []
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATING a secret only.
|
||||
|
||||
There is deliberately no read permission: the recipient usually has no
|
||||
account here, and the unguessable token is what authorises the read.
|
||||
Gating that side would make the tool useless for the case it exists for -
|
||||
handing somebody a credential while you are on a call with them.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the record of a secret that had not been read yet.
|
||||
"""
|
||||
return [
|
||||
('tools.secrets', 'Create secure send links', 'tools'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user