diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs
new file mode 100644
index 00000000..56852fa1
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs
@@ -0,0 +1,68 @@
+using System.Data.Common;
+using System.Globalization;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+
+namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
+
+///
+/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
+/// internal here — scripts call QuerySingle/Query by name and never reference
+/// System.Data. Named connections only; parameters are bound (anonymous-object
+/// properties become @-prefixed SQL parameters), never string-concatenated.
+///
+public sealed class InboundDatabaseHelper
+{
+ private readonly IDatabaseGateway _gateway;
+ private readonly CancellationToken _ct;
+
+ public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct)
+ { _gateway = gateway; _ct = ct; }
+
+ /// First column of the first row converted to T (default if no rows).
+ public T? QuerySingle(string connectionName, string sql, object? parameters = null)
+ {
+ using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ AddParameters(cmd, parameters);
+ var result = cmd.ExecuteScalar();
+ if (result is null or DBNull) return default;
+ if (result is T t) return t;
+ return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
+ }
+
+ /// All rows as column→value dictionaries (case-insensitive keys).
+ public IReadOnlyList> Query(
+ string connectionName, string sql, object? parameters = null)
+ {
+ using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ AddParameters(cmd, parameters);
+ using var reader = cmd.ExecuteReader();
+ var rows = new List>();
+ while (reader.Read())
+ {
+ var row = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 0; i < reader.FieldCount; i++)
+ {
+ var v = reader.GetValue(i);
+ row[reader.GetName(i)] = v is DBNull ? null : v;
+ }
+ rows.Add(row);
+ }
+ return rows;
+ }
+
+ private static void AddParameters(DbCommand cmd, object? parameters)
+ {
+ if (parameters is null) return;
+ foreach (var prop in parameters.GetType().GetProperties())
+ {
+ var p = cmd.CreateParameter();
+ p.ParameterName = "@" + prop.Name;
+ p.Value = prop.GetValue(parameters) ?? DBNull.Value;
+ cmd.Parameters.Add(p);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs
new file mode 100644
index 00000000..e50becd6
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs
@@ -0,0 +1,47 @@
+using System.Data.Common;
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.InboundAPI;
+using Xunit;
+
+public class InboundDatabaseHelperTests
+{
+ private sealed class SqliteGateway : IDatabaseGateway
+ {
+ private readonly string _cs;
+ public SqliteGateway(string cs) => _cs = cs;
+ public async Task GetConnectionAsync(string name, CancellationToken ct = default)
+ { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
+ public Task CachedWriteAsync(string c, string s,
+ IReadOnlyDictionary? p = null, string? o = null, CancellationToken ct = default,
+ TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
+ => throw new NotImplementedException();
+ }
+
+ private static SqliteGateway SeededGateway()
+ {
+ var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared");
+ keep.Open(); // keep-alive: shared in-memory db lives until process exit
+ using var cmd = keep.CreateCommand();
+ cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
+ cmd.ExecuteNonQuery();
+ return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared");
+ }
+
+ [Fact]
+ public void QuerySingle_returns_first_column_with_bound_parameter()
+ {
+ var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
+ var code = helper.QuerySingle("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
+ Assert.Equal("Z28061A", code);
+ }
+
+ [Fact]
+ public void QuerySingle_returns_default_when_no_rows()
+ {
+ var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
+ var code = helper.QuerySingle("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
+ Assert.Null(code);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj
index 2af82258..6dc60653 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj
@@ -15,6 +15,7 @@
+