Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion apps/dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,10 @@ function Connections({ me, run }: { me: Me; run: Run }) {
</>
)}
</dl>
{me.user.role === 'admin' && (
{server.id.startsWith('store-') && (
<p className="muted">Managed in Stores</p>
)}
{me.user.role === 'admin' && !server.id.startsWith('store-') && (
<div className="actions">
<button onClick={() => setEditing(server)}>Edit</button>
<button
Expand Down
11 changes: 5 additions & 6 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Automated test suite

`npm test` runs 66 deterministic unit and protocol tests across fifteen test files without making paid OpenAI calls:
`npm test` runs 67 deterministic unit and protocol tests across fifteen test files without making paid OpenAI calls:

- **`tests/gateway.test.ts`:** API key HMAC digest lookup, revoked-key attribution after restart, `<slug>__<alias>` tool collision routing (`time__get_status` vs `ops__get_status`), immediate route removal on disable, hung upstream timeout isolation, uncertain mutation auditing without retries, argument schema validation, cross-team route isolation, audit-failure fail-closed behavior, and full MCP SDK Streamable HTTP exchanges.
- **`tests/mcp-notifications.test.ts`:** Stateful Streamable HTTP + GET SSE sessions, team-scoped `notifications/tools/list_changed` delivery, suppression of unchanged catalog refreshes, cross-user/cross-key session rejection (`403`), revoked-key notification cutoff, per-user/global session limits (`429`), `DELETE` cleanup, and idle session expiry (`404`).
Expand All @@ -29,11 +29,10 @@ All cluster scripts run against the local k3d deployment (`storelink` namespace)

## Multi-store bonus acceptance

`node scripts/smoke-multistore.ts` passed locally on 2026-10-26. Two users created independent stores from the dashboard. The check reproduced a terminal bootstrap failure, retried through the UI with a new attempt, restarted the provisioner and confirmed that credentials survived unchanged. A real concurrent admission test accepted one request and rejected the other with 409 when one personal slot remained. Members could not delete/retry another user's store.

The gateway discovered ten additional tools. Cross-store connections to WordPress and MariaDB were blocked while authorized calls worked. COD checkout created one order per store, totaling ₹149.00 and ₹249.00. One dashboard chat turn called the time server and both stores' order tools and compared the totals. Deleting one store removed its tools while the other stayed usable; final deletion removed both namespaces and all four PVs, returning the gateway to nine tools.

Private evidence: `.data/multi-store-evidence.json` and `.cache/multi-store-*.png`. The test cleans up its stores/products/orders and preserves the chat and audit evidence. Deterministic tests in `tests/stores.test.ts` and `tests/store-requests.test.ts` cover manifests, namespace safety, attempt-based retries, token crash recovery, TLS readiness, quota admission and state transitions. The check also passed with the platform namespace’s optional default-deny mode enabled, after which its original setting was restored. The final run used 7,458 input / 278 output model tokens. Both runs used local HTTP; public DNS/certificate issuance and disaster restore are not claimed. See [operations](multi-store.md).
- **`node scripts/smoke-multistore.ts`:** Creates stores through two users' dashboards, checks ownership, and admits exactly one concurrent request when one personal slot remains. Discovers ten additional tools, places a COD order in each store (₹149.00 and ₹249.00), and uses dashboard chat to compare today's totals through the time server and both stores' order tools. Deleting one store removes its tools while the other remains usable; final deletion removes both namespaces and all four PVs, returning the gateway to nine tools.
- **Recovery and isolation:** Forces a terminal bootstrap failure, retries through the UI with a new attempt, restarts the provisioner, and checks that credentials remain unchanged. Blocks cross-store WordPress/MariaDB connections while authorized calls work. Covers platform default-deny mode both off and on, restoring the original setting afterward.
- **Deterministic coverage:** `tests/stores.test.ts` and `tests/store-requests.test.ts` cover manifests, namespace safety, attempt-based retries, token crash recovery, TLS readiness, atomic admission, deletion-collision messages and state transitions.
- **Evidence and scope:** `.data/multi-store-evidence.json` and `.cache/multi-store-*.png` contain private evidence. The script makes one paid, read-only model turn, cleans up its stores/products/orders, and preserves chat/audit evidence. Checks use local HTTP; public DNS/certificate issuance and disaster restore are outside this coverage. See [operations](multi-store.md).

## External MCP client verification

Expand Down
12 changes: 12 additions & 0 deletions services/gateway/src/store-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export async function requestStore(
// A short database admission transaction, independent of the long-running worker.
// The shared lock makes limits authoritative across gateway replicas.
await client.query('SELECT pg_advisory_xact_lock(87264005)');
const existing = await client.query<{ status: string }>(
'SELECT status FROM stores WHERE slug=$1',
[input.slug],
);
if (existing.rows[0])
throw new StoreRequestError(
409,
existing.rows[0].status === 'Deleting'
? 'That store address is still being removed; wait a moment and try again'
: 'That store name is in use',
);

const { rows } = await client.query<{ total: string; mine: string }>(
'SELECT count(*) AS total,count(*) FILTER (WHERE created_by=$2) AS mine FROM stores WHERE team_id=$1',
[user.teamId, user.userId],
Expand Down
50 changes: 47 additions & 3 deletions tests/store-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ const settings = {
maxPerTeam: 3,
provisionTimeoutMs: 240000,
};
function fixture(existing: { team: string; owner: string }[]) {
type Record = { team: string; owner: string; slug?: string; status?: string };
function fixture(existing: Record[]) {
const records = [...existing];
let tail = Promise.resolve();
let rollbacks = 0;
const pool = {
async connect() {
let release: (() => void) | undefined;
let pending: { team: string; owner: string } | undefined;
let pending: Record | undefined;
return {
async query(sql: string, args: unknown[] = []) {
if (sql === 'BEGIN') return { rows: [] };
Expand All @@ -39,6 +40,12 @@ function fixture(existing: { team: string; owner: string }[]) {
await previous;
return { rows: [] };
}
if (sql === 'SELECT status FROM stores WHERE slug=$1')
return {
rows: records
.filter((r) => r.slug === args[0])
.map((r) => ({ status: r.status })),
};
if (sql.includes('count(*) AS total')) {
const team = records.filter((r) => r.team === args[0]);
return {
Expand All @@ -53,7 +60,12 @@ function fixture(existing: { team: string; owner: string }[]) {
if (sql.includes('SELECT 1 FROM upstream_servers'))
return { rows: [], rowCount: 0 };
if (sql.includes('INSERT INTO stores')) {
pending = { team: String(args[1]), owner: String(args[2]) };
pending = {
team: String(args[1]),
owner: String(args[2]),
slug: String(args[3]),
status: 'Provisioning',
};
return { rows: [], rowCount: 1 };
}
if (sql.includes('pg_notify')) return { rows: [] };
Expand Down Expand Up @@ -121,3 +133,35 @@ test('store admission enforces per-user limits independently of another team and
assert.equal(f.records.length, 3);
assert.equal(f.rollbacks(), 1);
});

test('a deleting address reports termination across teams and can be reused after removal', async () => {
for (const team of ['team', 'another-team']) {
const f = fixture([
{ team, owner: 'other', slug: 'north', status: 'Deleting' },
]);
await assert.rejects(
() =>
requestStore(
f.pool,
user,
{ slug: 'north', displayName: 'North' },
{ ...settings, maxPerTeam: 1 },
),
(error) =>
error instanceof StoreRequestError &&
error.status === 409 &&
error.message ===
'That store address is still being removed; wait a moment and try again',
);
assert.equal(f.records.length, 1);
assert.equal(f.rollbacks(), 1);
f.records.length = 0;
await requestStore(
f.pool,
user,
{ slug: 'north', displayName: 'North' },
{ ...settings, maxPerTeam: 1 },
);
assert.equal(f.records.length, 1);
}
});
Loading