Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ reviews:
Verify eventId and tenantId are always set.
chat:
auto_reply: true

1 change: 1 addition & 0 deletions apps/bff/src/datasources/cassandra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ export async function closeCassandraClient(): Promise<void> {
client = null;
}
}

1 change: 1 addition & 0 deletions apps/bff/src/datasources/elasticsearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ export const search = {
}
},
};

158 changes: 95 additions & 63 deletions apps/bff/src/datasources/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Pool } from "pg";
import { postgresCircuitBreaker } from "../lib/circuitBreaker";
import { cache } from "./redis";

const pool = new Pool({
host: process.env.READ_DB_HOST || "postgres-read",
host: process.env.READ_DB_HOST || "pgbouncer",
port: parseInt(process.env.READ_DB_PORT || "5432"),
database: process.env.READ_DB_NAME || "grainguard_read",
user: process.env.READ_DB_USER || "postgres",
password: process.env.READ_DB_PASSWORD || "postgres",
max: 10,
max: 50,
});


// Circuit-breaker-wrapped query helper
async function cbQuery(text: string, values?: any[]): Promise<import("pg").QueryResult<any>> {
return postgresCircuitBreaker.execute(() => pool.query(text, values));
Expand All @@ -33,9 +33,6 @@ export async function tenantQuery(
});
}

// Tenant-scoped query — sets app.current_tenant_id for RLS enforcement
// Use this for all queries that should be tenant-isolated

export const db = {

async getDevice(deviceId: string) {
Expand All @@ -49,14 +46,29 @@ export const db = {
},

async getAllDevices(limit: number = 20) {
const result = await cbQuery(
`SELECT device_id, tenant_id, serial_number, created_at
FROM device_projections
ORDER BY created_at DESC
LIMIT $1`,
[limit]
);
return result.rows;
const cacheKey = `devices:all:${limit}`;
const cached = await cache.get<any[]>(cacheKey);
if (cached) return cached;

const locked = await cache.acquireLock(cacheKey, 5);
if (!locked) {
await new Promise(r => setTimeout(r, 100));
return await cache.get<any[]>(cacheKey) || [];
}

try {
const result = await cbQuery(
`SELECT device_id, tenant_id, serial_number, created_at
FROM device_projections
ORDER BY created_at DESC
LIMIT $1`,
[limit]
);
await cache.set(cacheKey, result.rows, 30);
Comment on lines +49 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make this cache path tenant-aware or explicitly admin-only.

devices:all:${limit} is a shared cross-tenant key, and the query still returns rows for every tenant. If this method is reachable from normal tenant-scoped resolver flows, one caller can warm a payload that every other caller reuses. Thread tenantId through this API and key/query on it, or split it into an explicitly admin-only path. As per coding guidelines, apps/bff/src/**/*.ts: Check all queries use tenantId for RLS. Verify cache keys include tenantId.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/bff/src/datasources/postgres.ts` around lines 49 - 67, The cache key and
query are tenant-agnostic: update the call site and this function to accept a
tenantId (or an explicit isAdmin flag), include tenantId in the cache key (e.g.,
`devices:tenant:${tenantId}:all:${limit}`) and add a WHERE tenant_id = $2 (or
equivalent) to the cbQuery so the SQL filters by tenant; if this endpoint must
be admin-only instead, document and enforce that by checking an isAdmin guard
before using the shared key. Ensure the cache.get/set/acquireLock use the new
tenant-scoped key and thread tenantId through any callers that invoke this code
so RLS/tenant isolation is preserved.

return result.rows;
} finally {
await cache.releaseLock(cacheKey);
Comment on lines +49 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not let cache contention or Redis failures turn into empty/failed reads.

On a contested miss, the loser sleeps 100ms and then returns [] if the winner has not populated Redis yet. Separately, any cache.get / set / releaseLock error will reject the request, even when Postgres is healthy or the DB query already succeeded. These paths should fall back to the underlying DB query whenever the cache cannot serve a value.

Also applies to: 85-120, 165-205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/bff/src/datasources/postgres.ts` around lines 49 - 70, The cache logic
currently returns [] on contested misses and lets any cache
(get/set/releaseLock) errors surface instead of falling back to Postgres; update
the flow around cacheKey, cache.get, cache.acquireLock, cache.set and
cache.releaseLock so that any cache error or a contested-miss timeout triggers a
direct cbQuery to Postgres and returns those rows, and ensure cache.releaseLock
errors are caught/logged and do not override the DB result. Concretely: on cache
miss, attempt acquireLock; if not locked, sleep then try cache.get again and if
still empty call cbQuery(...) and return its rows; wrap all
cache.get/set/releaseLock calls in try/catch to fall back to cbQuery on failure;
after a successful cbQuery, attempt cache.set but ignore/set-log any errors so
the DB result is still returned. This same pattern should be applied to the
other similar blocks indicated (lines ~85-120 and ~165-205).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},

async getDeviceTelemetry(deviceId: string) {
Expand All @@ -70,26 +82,43 @@ export const db = {
},

async getAllTelemetry(limit: number = 20, tenantId?: string) {
if (tenantId) {
const cacheKey = `telemetry:all:${tenantId || "global"}:${limit}`;
const cached = await cache.get<any[]>(cacheKey);
if (cached) return cached;

const locked = await cache.acquireLock(cacheKey, 5);
if (!locked) {
await new Promise(r => setTimeout(r, 100));
return await cache.get<any[]>(cacheKey) || [];
}

try {
if (tenantId) {
const result = await cbQuery(
`SELECT t.device_id, t.temperature, t.humidity, t.recorded_at, t.updated_at, t.version
FROM device_telemetry_latest t
INNER JOIN device_projections d ON d.device_id = t.device_id
WHERE d.tenant_id = $1
ORDER BY t.updated_at DESC
LIMIT $2`,
[tenantId, limit]
);
Comment on lines +96 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the tenant-scoped query path on these branches.

These branches already have a tenantId, but they still call cbQuery, so the new cached reads are bypassing the module's tenant/RLS path and relying only on ad hoc predicates. Route them through the tenant-scoped helper here, keeping the explicit tenant_id filter if you want defense in depth. As per coding guidelines, apps/bff/src/**/*.ts: Check all queries use tenantId for RLS.

Also applies to: 176-187

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/bff/src/datasources/postgres.ts` around lines 96 - 105, The branch is
calling cbQuery directly despite having a tenantId, bypassing the module's
tenant/RLS pathway; replace the cbQuery call in this block (the query selecting
from device_telemetry_latest joined to device_projections) with the module's
tenant-scoped query helper (e.g., the tenantScopedQuery/tenantQuery helper used
elsewhere) and pass tenantId and limit through that helper while keeping the
explicit WHERE d.tenant_id = $1 predicate for defense-in-depth; apply the same
replacement for the analogous block around lines 176-187 so all tenant-aware
reads use the tenant-scoped helper rather than cbQuery.

await cache.set(cacheKey, result.rows, 30);
return result.rows;
}

const result = await cbQuery(
`SELECT t.device_id, t.temperature, t.humidity, t.recorded_at, t.updated_at, t.version
FROM device_telemetry_latest t
INNER JOIN device_projections d ON d.device_id = t.device_id
WHERE d.tenant_id = $1
ORDER BY t.updated_at DESC
LIMIT $2`,
[tenantId, limit]
`SELECT device_id, temperature, humidity, recorded_at, updated_at, version
FROM device_telemetry_latest
ORDER BY updated_at DESC
LIMIT $1`,
[limit]
);
await cache.set(cacheKey, result.rows, 30);
return result.rows;
} finally {
await cache.releaseLock(cacheKey);
}
const result = await cbQuery(
`SELECT device_id, temperature, humidity, recorded_at, updated_at, version
FROM device_telemetry_latest
ORDER BY updated_at DESC
LIMIT $1`,
[limit]
);
return result.rows;
},

async getDeviceWithTelemetry(deviceId: string) {
Expand Down Expand Up @@ -133,45 +162,48 @@ export const db = {
},

async getAllDevicesWithTelemetry(limit: number = 20, tenantId?: string) {
if (tenantId) {
const cacheKey = `devices:telemetry:${tenantId || "global"}:${limit}`;
const cached = await cache.get<any[]>(cacheKey);
if (cached) return cached;

const locked = await cache.acquireLock(cacheKey, 5);
if (!locked) {
await new Promise(r => setTimeout(r, 100));
return await cache.get<any[]>(cacheKey) || [];
}

try {
if (tenantId) {
const result = await cbQuery(
`SELECT
d.device_id, d.tenant_id, d.serial_number, d.created_at,
t.temperature, t.humidity, t.recorded_at, t.version
FROM device_projections d
LEFT JOIN device_telemetry_latest t ON d.device_id = t.device_id
WHERE d.tenant_id = $1
ORDER BY d.created_at DESC
LIMIT $2`,
[tenantId, limit]
);
await cache.set(cacheKey, result.rows, 30);
return result.rows;
}

const result = await cbQuery(
`SELECT
d.device_id,
d.tenant_id,
d.serial_number,
d.created_at,
t.temperature,
t.humidity,
t.recorded_at,
t.version
d.device_id, d.tenant_id, d.serial_number, d.created_at,
t.temperature, t.humidity, t.recorded_at, t.version
FROM device_projections d
LEFT JOIN device_telemetry_latest t
ON d.device_id = t.device_id
WHERE d.tenant_id = $1
LEFT JOIN device_telemetry_latest t ON d.device_id = t.device_id
ORDER BY d.created_at DESC
LIMIT $2`,
[tenantId, limit]
LIMIT $1`,
[limit]
);
await cache.set(cacheKey, result.rows, 30);
return result.rows;
} finally {
await cache.releaseLock(cacheKey);
}
const result = await cbQuery(
`SELECT
d.device_id,
d.tenant_id,
d.serial_number,
d.created_at,
t.temperature,
t.humidity,
t.recorded_at,
t.version
FROM device_projections d
LEFT JOIN device_telemetry_latest t
ON d.device_id = t.device_id
ORDER BY d.created_at DESC
LIMIT $1`,
[limit]
);
return result.rows;
},

async getDevicesWithCursor(first: number = 20, after: string | null = null, tenantId?: string) {
Expand Down Expand Up @@ -264,4 +296,4 @@ export const db = {
},
};
},
};
};
2 changes: 1 addition & 1 deletion apps/bff/src/datasources/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ export const cache = {
async releaseLock(key: string): Promise<void> {
await client.del(`lock:${key}`);
}
};
};
1 change: 1 addition & 0 deletions apps/bff/src/lib/circuitBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,4 @@ export const postgresCircuitBreaker = new CircuitBreaker({
successThreshold: 2,
timeout: 30_000,
});

1 change: 1 addition & 0 deletions apps/bff/src/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export const pubsub = new PubSub();

export const TELEMETRY_UPDATED = "TELEMETRY_UPDATED";
export const TENANT_TELEMETRY_UPDATED = "TENANT_TELEMETRY_UPDATED";

1 change: 1 addition & 0 deletions apps/bff/src/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,4 @@ export const resolvers = {
},
},
};

1 change: 1 addition & 0 deletions apps/bff/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,4 @@
tenantTelemetryUpdated(tenantId: String!): Telemetry!
}
`;

1 change: 1 addition & 0 deletions apps/bff/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,4 @@ startServer().catch((err) => {
console.error("Failed to start BFF:", err);
process.exit(1);
});

2 changes: 1 addition & 1 deletion apps/bff/src/telemetryWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ export async function startTelemetryWatcher() {
});

console.log("Telemetry watcher started — LISTEN telemetry_updated");
}
}
1 change: 1 addition & 0 deletions apps/cassandra-writer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,4 @@ func init() {
// Ensure uuid package is used
_ = uuid.New()
}

1 change: 1 addition & 0 deletions apps/cdc-transformer/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,4 @@ func isSkippable(err error) bool {
}
return errors.Is(err, context.Canceled)
}

1 change: 1 addition & 0 deletions apps/cdc-transformer/internal/consumer/cdc_consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,3 +460,4 @@ func isSkippable(err error) bool {
return errors.Is(err, context.Canceled) ||
strings.HasPrefix(s, "skip:")
}

2 changes: 1 addition & 1 deletion apps/cdc-transformer/internal/idempotency/dedupe.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ func (d *Deduper) Confirm(ctx context.Context, key string) error {
// Cancel removes reservation when publish fails so it can retry.
func (d *Deduper) Cancel(ctx context.Context, key string) error {
return d.rdb.Del(ctx, key).Err()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,4 @@ func normalizeTime(t string) string {
}

return time.Now().UTC().Format(time.RFC3339Nano)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,4 @@ func TestMarshalTelemetry_ProducesValidJSON(t *testing.T) {
t.Errorf("eventType: want telemetry.recorded got %v", result["eventType"])
}
}

2 changes: 1 addition & 1 deletion apps/dashboard/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ const config: StorybookConfig = {
],
"framework": "@storybook/react-vite"
};
export default config;
export default config;
2 changes: 1 addition & 1 deletion apps/dashboard/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ const preview: Preview = {
},
};

export default preview;
export default preview;
2 changes: 1 addition & 1 deletion apps/dashboard/.storybook/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import * as projectAnnotations from './preview';

// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
1 change: 1 addition & 0 deletions apps/dashboard/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export default defineConfig([globalIgnores(['dist']), {
globals: globals.browser,
},
}, ...storybook.configs["flat/recommended"]])

1 change: 1 addition & 0 deletions apps/dashboard/src/features/devices/hooks/useDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ export function useSearchDevices(query: string, limit = 20) {
error,
};
}

Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ export const SEARCH_DEVICES = gql`
}
}
`;

2 changes: 1 addition & 1 deletion apps/dashboard/src/features/devices/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export interface DeviceTelemetryHistory {
temperature: number | null;
humidity: number | null;
recordedAt: string | null;
}
}
1 change: 1 addition & 0 deletions apps/dashboard/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export function useAuth() {
getToken,
};
}

2 changes: 1 addition & 1 deletion apps/dashboard/src/hooks/useDarkMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export function useDarkMode() {
}, [isDark]);

return { isDark, toggle: () => setIsDark((d) => !d) };
}
}
2 changes: 1 addition & 1 deletion apps/dashboard/src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ export function useDebounce<T>(value: T, delay: number): T {
}, [value, delay]);

return debouncedValue;
}
}
1 change: 1 addition & 0 deletions apps/dashboard/src/hooks/useDeviceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ export function useTenantTelemetrySubscription(tenantId: string) {
error,
};
}

2 changes: 1 addition & 1 deletion apps/dashboard/src/lib/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,4 @@ const client = new ApolloClient({
cache: new InMemoryCache(),
});

export default client;
export default client;
5 changes: 4 additions & 1 deletion apps/dashboard/src/lib/auth0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ export async function getAccessTokenSilently(options?: any): Promise<string> {
if (!_getToken) throw new Error("Auth0 not initialized");
return _getToken(options);
}
(window as any).__getToken = getAccessTokenSilently;

if (process.env.NODE_ENV === "development") {
(window as any).__getToken = getAccessTokenSilently;
}
1 change: 1 addition & 0 deletions apps/dashboard/src/stories/Button.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ export const Small: Story = {
label: 'Button',
},
};

1 change: 1 addition & 0 deletions apps/dashboard/src/stories/Header.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export const LoggedIn: Story = {
};

export const LoggedOut: Story = {};

Loading
Loading