From e178bba9c97a2e97a3405fbbfe704553c023a59c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sat, 1 Aug 2026 21:20:52 +0900 Subject: [PATCH 1/3] adding update legal script --- scripts/ShowReleaseMsg.txt | 117 +++++++++++++++++++++++++++++++++++++ scripts/Update-Legal.sql | 6 +- 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 scripts/ShowReleaseMsg.txt diff --git a/scripts/ShowReleaseMsg.txt b/scripts/ShowReleaseMsg.txt new file mode 100644 index 0000000..b320559 --- /dev/null +++ b/scripts/ShowReleaseMsg.txt @@ -0,0 +1,117 @@ +================================================================================ + CropWatch "What's New" — publishing a release announcement + (and how to get an LLM to write the messages for you) +================================================================================ + +WHAT THIS IS +------------ +Users see a one-time "What's New" dialog after login whenever a new release is +flagged. The mechanism has two halves: + + 1. CONTENT lives in the CropWatch app repo (NOT the database): + - messages/en.json + messages/ja.json → whats_new_r{N}_item{X}_title/_body keys + - src/lib/components/whats-new/WhatsNewDialog.svelte + → RELEASE_ITEMS array (which keys to show) + → WHATS_NEW_CONTENT_RELEASE constant (which release this build describes) + + 2. ACTIVATION is a single DB row (public.whats_new). The dialog only shows + when the DB's current_release is BOTH greater than what the user has seen + AND exactly equal to the app's WHATS_NEW_CONTENT_RELEASE. So flagging a + release before (or after) the matching app deploy is always safe — the + dialog just stays silent until both match. + +ACTIVATION SQL (run in Supabase SQL editor AFTER the app deploy for release N): + + UPDATE public.whats_new SET current_release = 1, published_at = now() WHERE key = 'app'; + + -- change "1" to the release number that matches WHATS_NEW_CONTENT_RELEASE + -- in the deployed app. + + +================================================================================ + HOW TO GET AN LLM (ChatGPT, Claude, etc.) TO WRITE THE MESSAGES +================================================================================ + +You write rough notes about what shipped; the LLM turns them into polished, +translated, correctly-keyed message entries. Copy the prompt below, fill in the +two placeholders, and paste it into the LLM. + +--------------------------- COPY FROM HERE ------------------------------------- + +You are writing user-facing release notes for CropWatch, a LoRaWAN +agricultural / environmental sensor monitoring web app used by farmers and +site managers in English and Japanese. + +Here are my rough notes on what shipped (may be terse or unordered): + +<<< PASTE YOUR ROUGH NOTES HERE, e.g.: +- can update email on profile page now +- dew point shown on temp/humidity devices +>>> + +The release number is: <<< N >>> + +Produce EXACTLY three outputs: + +1. JSON lines for messages/en.json — one _title/_body pair per item, keys + numbered whats_new_r_item1 ... itemX in the order given: + + "whats_new_r_item1_title": "...", + "whats_new_r_item1_body": "...", + +2. The same keys for messages/ja.json with natural Japanese translations + (polite です/ます register, keep product terms like LINE / CropWatch as-is). + +3. The RELEASE_ITEMS array literal for WhatsNewDialog.svelte listing every + item, in this exact shape: + + const RELEASE_ITEMS = [ + { title: m.whats_new_r_item1_title, body: m.whats_new_r_item1_body }, + ... + ]; + +Writing rules: +- Title: 2–5 words, plain language, names the feature (no trailing period). +- Body: ONE sentence, states the user benefit and where to find it + (e.g. "from your account page"), no jargon, no marketing fluff, no + exclamation marks. +- Do not invent features I did not list. Ask me if a note is unclear. +- Output only the three blocks above, nothing else. + +--------------------------- COPY TO HERE --------------------------------------- + + +================================================================================ + WHAT TO DO WITH THE LLM'S OUTPUT (CropWatch app repo) +================================================================================ + +1. Paste block 1 into messages/en.json and block 2 into messages/ja.json, + next to the existing whats_new_* keys (keep both files' key lists identical). + +2. In src/lib/components/whats-new/WhatsNewDialog.svelte: + - replace the RELEASE_ITEMS array with block 3 + - bump: const WHATS_NEW_CONTENT_RELEASE = ; + +3. Regenerate + verify (repo root): + pnpm run paraglide (or just `pnpm run check`, which includes it) + pnpm run check + pnpm run lint + +4. Test locally before deploying: + UPDATE public.whats_new SET current_release = , published_at = now() WHERE key = 'app'; + -- reload the app: dialog appears once; dismiss; reload: gone. + -- to see it again: + DELETE FROM public.profile_whats_new_seen WHERE user_id = ''; + +5. Deploy the app. THEN run the activation SQL against prod (step order + matters only for timing — a mismatch is silent, never broken). + +NOTES +----- +- Old releases' keys (whats_new_r1_*, ...) can stay in the json files forever; + only the keys referenced by RELEASE_ITEMS are shown. +- Aim for 3–6 items; the dialog body scrolls but shorter is better. +- Per-user seen state is in public.profile_whats_new_seen (one row per user, + release they last dismissed). public.whats_new is the single-row flag. +- Full mechanism + tables: api/supabase/updates/020_whats_new.sql +================================================================================ diff --git a/scripts/Update-Legal.sql b/scripts/Update-Legal.sql index 78e9d63..9627d27 100644 --- a/scripts/Update-Legal.sql +++ b/scripts/Update-Legal.sql @@ -15,9 +15,9 @@ SELECT u.kind, u.url, u.effective_at FROM (VALUES -- keep only the rows for the documents that changed - ('eula', 'https://www.cropwatch.io/legal/EULA', timestamptz '2026-09-01 00:00:00+09'), - ('terms_of_service', 'https://www.cropwatch.io/legal/terms-of-service', timestamptz '2026-09-01 00:00:00+09'), - ('privacy_policy', 'https://www.cropwatch.io/legal/privacy-policy', timestamptz '2026-09-01 00:00:00+09') + ('eula', 'https://www.cropwatch.co.jp/legal/EULA', timestamptz '2026-08-01 00:00:00+09'), + -- ('terms_of_service', 'https://www.cropwatch.co.jp/legal/terms-of-service', timestamptz '2026-08-01 00:00:00+09'), + -- ('privacy_policy', 'https://www.cropwatch.co.jp/legal/privacy-policy', timestamptz '2026-08-01 00:00:00+09') ) AS u(kind, url, effective_at); -- Review scheduled-but-not-yet-effective updates: From fa3be7d5a4a0109114a861e2f32751d8f7a2e1b2 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 25 Aug 2026 13:21:36 +0900 Subject: [PATCH 2/3] adding in LINE and Push notifications --- docs/waf-login-rate-limit-todo.md | 137 +++++++++ src/app.module.ts | 34 ++- src/main.ts | 60 ++-- src/v1/auth/auth.module.ts | 21 +- .../auth/strategies/supabase.strategy.spec.ts | 76 +++++ src/v1/auth/strategies/supabase.strategy.ts | 21 +- .../guards/user-throttler.guard.spec.ts | 54 ++++ src/v1/common/guards/user-throttler.guard.ts | 43 +++ src/v1/devices/devices.controller.ts | 39 +-- src/v1/devices/devices.service.spec.ts | 107 +++++++ src/v1/devices/devices.service.ts | 11 +- src/v1/line/line.controller.ts | 5 + src/v1/payments/payments.controller.ts | 5 + src/v1/push/dto/register-push-token.dto.ts | 19 ++ src/v1/push/push.controller.spec.ts | 77 +++++ src/v1/push/push.controller.ts | 105 +++++++ src/v1/push/push.module.ts | 12 + src/v1/push/push.service.spec.ts | 289 ++++++++++++++++++ src/v1/push/push.service.ts | 172 +++++++++++ src/v1/rules/dto/rule-catalog.dto.ts | 30 ++ src/v1/rules/dto/rule-device-state.dto.ts | 37 +++ src/v1/rules/rules.controller.spec.ts | 45 +++ src/v1/rules/rules.controller.ts | 38 +++ src/v1/rules/rules.service.spec.ts | 90 ++++++ src/v1/rules/rules.service.ts | 127 ++++++++ supabase/updates/022_push_tokens.sql | 28 ++ supabase/updates/023_push_action_type.sql | 21 ++ 27 files changed, 1618 insertions(+), 85 deletions(-) create mode 100644 docs/waf-login-rate-limit-todo.md create mode 100644 src/v1/auth/strategies/supabase.strategy.spec.ts create mode 100644 src/v1/common/guards/user-throttler.guard.spec.ts create mode 100644 src/v1/common/guards/user-throttler.guard.ts create mode 100644 src/v1/push/dto/register-push-token.dto.ts create mode 100644 src/v1/push/push.controller.spec.ts create mode 100644 src/v1/push/push.controller.ts create mode 100644 src/v1/push/push.module.ts create mode 100644 src/v1/push/push.service.spec.ts create mode 100644 src/v1/push/push.service.ts create mode 100644 src/v1/rules/dto/rule-catalog.dto.ts create mode 100644 src/v1/rules/dto/rule-device-state.dto.ts create mode 100644 supabase/updates/022_push_tokens.sql create mode 100644 supabase/updates/023_push_action_type.sql diff --git a/docs/waf-login-rate-limit-todo.md b/docs/waf-login-rate-limit-todo.md new file mode 100644 index 0000000..ae879d9 --- /dev/null +++ b/docs/waf-login-rate-limit-todo.md @@ -0,0 +1,137 @@ +# TODO: Vercel WAF rate limit on `/v1/auth/login` (deferred) + +**Status:** Not yet applied — deferred until we're comfortable making firewall +changes on the `api.cropwatch.io` Vercel project. This is config, not code; it +does not ship with the codebase and must be run against Vercel directly. + +**Goal:** Stop DoS / brute-force floods against the login endpoint at Vercel's +edge, *before* they reach the function (and before they trigger the expensive +Supabase password check). + +## Why the edge, not the app + +The app-level throttler (`src/app.module.ts` `ThrottlerModule` + +`src/v1/common/guards/user-throttler.guard.ts`, plus the `@Throttle(2/60)` on +`POST /v1/auth/login` in `src/v1/auth/auth.controller.ts`) is a **backstop, not +a DoS shield**: + +- It runs *inside* the function, so a flood still costs an invocation and still + runs guard code before it can say "no". +- Its counter is in-memory and **per-instance**; Vercel spins up many instances, + each with its own count, so the real limit is much looser than configured and + blocks don't propagate. +- It can't stop distributed attacks. + +The WAF sits at Vercel's edge: it blocks **before** the function runs, counts +**globally**, and **Vercel does not bill for blocked traffic**. Platform DDoS +mitigation (L3/L4/L7) is already on for free underneath this. + +## The nuance for our architecture (read before setting the number) + +The WAF rate-limits **by IP**. That's ideal for an attacker (they hit +`api.cropwatch.io/v1/auth/login` directly from their own IP/botnet), but our +**legit** logins arrive from **Vercel's shared egress IPs** — the web app's +SvelteKit server action (`CropWatch/src/lib/server/auth/login-action.ts`) calls +the API server-side. So a too-tight per-IP limit could clip real users clustered +on Vercel IPs. → **Always log first, read real traffic, then enforce.** + +Two more, because login is a JSON API (not a browser page): + +- Use **`deny` (403)** or **`rate_limit` (429)** when the limit trips — **not + `challenge`** (an HTML challenge page would break the web app's `fetch` and the + Android widget, which expect JSON). +- WAF counters are **per-region**, so the effective global limit is ~N× the + number. Fine for login, just expected. + +## Step 0 — prerequisites (in this repo) + +```bash +vercel login +vercel link # link to the api.cropwatch.io project +``` + +## Step 1 — stage the rule in LOG mode (blocks nothing) + +```bash +vercel firewall rules add "Rate limit login" \ + --condition '{"type":"path","op":"eq","value":"/v1/auth/login"}' \ + --condition '{"type":"method","op":"eq","value":"POST"}' \ + --action rate_limit \ + --rate-limit-window 60 \ + --rate-limit-requests 30 \ + --rate-limit-keys ip \ + --rate-limit-action log \ + --yes + +vercel firewall diff # review the staged draft +vercel firewall publish --yes # make it live (log-only, safe) +``` + +## Step 2 — watch real traffic for ~a day + +Get the rule ID (`rule_…`) from `vercel firewall rules list --json`, then open: + +``` +https://vercel.com///firewall/traffic?filter= +``` + +Check one thing: **is anything legit exceeding 30/min?** + +- Only obvious attackers trip it → proceed to Step 3. +- Real users (clustered Vercel IPs) trip it → raise `--rate-limit-requests`. +- **Zero hits when you actually log in** → the WAF is matching the pre-rewrite + path. Change the first condition's `"type":"path"` to `"type":"raw_path"` and + re-publish. (Our `vercel.json` rewrites everything to `/src/main.ts`.) + +## Step 3 — enforce + +```bash +vercel firewall rules edit "Rate limit login" \ + --rate-limit-action deny \ + --rate-limit-requests 20 \ + --yes +vercel firewall diff && vercel firewall publish --yes +``` + +If the CLI rejects editing the rate-limit sub-flags, remove and re-add: + +```bash +vercel firewall rules remove "Rate limit login" --yes +# then re-run the Step 1 `add` with --rate-limit-action deny +``` + +Optional: add `--duration 15m` (Pro/Enterprise) so a tripped IP stays blocked +for 15 min instead of resetting each window. Keep the dashboard URL handy for the +first 24h in case a rollback is needed (`--rate-limit-action log` or +`rules disable "Rate limit login"`). + +## Optional refinement — zero collateral on legit traffic + +To fully separate legit app traffic from attackers: have the web app attach a +**secret header** (server-side only, never in the browser bundle) on its API +calls, and add a higher-priority WAF rule that **`bypass`es** the login rate +limit when that header is present. Then legit app logins are never limited, and +only direct hits (attackers) face the per-IP cap. + +- Tradeoff: the Android widget and any direct API callers *would* be subject to + the limit (fine — low volume, distinct IPs), and there's a shared secret to + manage. Keep the bypass narrow (secret header **plus** it never appears client + side). + +## Related follow-ups we chose NOT to do in this pass (app-level, code) + +If the WAF alone isn't enough later, these are the code-side layers: + +- **Per-email login throttle** — key the login throttle on the submitted email + instead of IP, so legit users on shared Vercel IPs never collide and one + account can't be brute-forced fast. (Backstop to the WAF.) +- **Per-account failed-attempt lockout / backoff** — defends against distributed + credential stuffing (one password vs many accounts from many IPs) that IP and + email limits miss. + +## References + +- Firewall CLI / WAF: https://vercel.com/docs/cli/firewall , + https://vercel.com/docs/vercel-firewall/vercel-waf/custom-rules +- Rate Limiting SDK (for custom counting/buckets): + https://vercel.com/docs/vercel-firewall/vercel-waf/rate-limiting-sdk diff --git a/src/app.module.ts b/src/app.module.ts index f54dc2a..ac54374 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,8 +9,9 @@ import { WaterModule } from './v1/water/water.module'; import { TrafficModule } from './v1/traffic/traffic.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; +import { UserThrottlerGuard } from './v1/common/guards/user-throttler.guard'; import { DevicesModule } from './v1/devices/devices.module'; import { RulesModule } from './v1/rules/rules.module'; import { ReportsModule } from './v1/reports/reports.module'; @@ -20,6 +21,7 @@ import { GatewayModule } from './v1/gateway/gateway.module'; import { DashboardModule } from './v1/dashboard/dashboard.module'; import { PaymentsModule } from './v1/payments/payments.module'; import { LineModule } from './v1/line/line.module'; +import { PushModule } from './v1/push/push.module'; import { CropwatchMcpModule } from './v1/mcp/mcp.module'; @Module({ @@ -33,20 +35,31 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'static'), }), + // Limits are keyed per user (bearer token) by UserThrottlerGuard, not per + // IP — the web app's SSR fans out many requests from a shared pool of Vercel + // egress IPs, so an IP-keyed limit would throttle everyone at once. + // NOTE: the default store is in-memory and per-instance; on Vercel the + // effective limit is (limit x concurrent instances) and blocks don't + // propagate. For hard, distributed enforcement use the Vercel WAF / a shared + // store — tracked separately. ThrottlerModule.forRoot([ { - // app wide, if you send more than 10 requests in 1 minute, you get a 2-minute ban. + // Burst window: 120 requests / 10s per user. Covers the heaviest + // legitimate client burst — a ~100-device dashboard foreground-resume + // fans out ~100 requests within 15s. Offenders blocked for 30s. name: 'default', - ttl: 2000, - limit: 2000, - blockDuration: 6000, + ttl: 10_000, + limit: 120, + blockDuration: 30_000, }, { - // If you send more than 100 requests in 1 minute, you get a 24-hour ban. + // Sustained window: 600 requests / minute per user, with headroom for + // steady polling (relay 30s, per-device refresh). Blocked for 60s on + // breach — no longer a 24h ban. name: 'long', - ttl: 60000, - limit: 2000, - blockDuration: 86400000, // 24 hours + ttl: 60_000, + limit: 600, + blockDuration: 60_000, }, ]), DevicesModule, @@ -58,9 +71,10 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; DashboardModule, PaymentsModule, LineModule, + PushModule, CropwatchMcpModule, ], controllers: [AppController], - providers: [AppService, { provide: APP_GUARD, useClass: ThrottlerGuard }], + providers: [AppService, { provide: APP_GUARD, useClass: UserThrottlerGuard }], }) export class AppModule {} diff --git a/src/main.ts b/src/main.ts index 5e26998..40710ed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,17 +13,10 @@ import { AllExceptionsFilter } from './v1/common/filters/all-exceptions.filter'; import { STATUS_CODES } from 'http'; import type { Express, NextFunction, Request, Response } from 'express'; +// With `trust proxy` pinned to a single hop (Vercel), Express resolves req.ip to +// the authentic client address (the right-most X-Forwarded-For entry Vercel +// appends), so we no longer hand-parse the spoofable left-most XFF entry. function getRequesterIp(req: Request): string { - const forwardedFor = req.headers['x-forwarded-for']; - - if (typeof forwardedFor === 'string' && forwardedFor.trim().length > 0) { - return forwardedFor.split(',')[0].trim(); - } - - if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { - return forwardedFor[0].split(',')[0].trim(); - } - return req.ip || req.socket.remoteAddress || 'unknown'; } @@ -35,8 +28,35 @@ async function bootstrap() { // The Express adapter's getInstance() is typed `any`; pin it once here. const expressApp = app.getHttpAdapter().getInstance() as Express; - expressApp.set('trust proxy', true); + // Vercel puts exactly one proxy hop in front of the function, so trust only + // that single hop. Express then resolves req.ip to the address Vercel appended + // (the right-most XFF entry) rather than a client-spoofable left-most one — the + // rate-limit tracker and request logs both depend on this being authentic. + expressApp.set('trust proxy', 1); app.enableCors(); + + // Register Helmet BEFORE the routes and Swagger below, so every response — + // including the Swagger UI and the /docs-json-* handlers — carries the security + // headers. (It was previously added after Swagger, leaving those routes bare.) + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", 'https://cdn.jsdelivr.net'], + styleSrc: [ + "'self'", + "'unsafe-inline'", + 'https://fonts.googleapis.com', + ], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], + imgSrc: ["'self'", 'data:'], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + }, + }, + }), + ); + app.use((req: Request, res: Response, next: NextFunction) => { const endpoint = req.originalUrl || req.url || 'unknown'; const method = req.method || 'UNKNOWN'; @@ -161,24 +181,6 @@ Developer notes: 'urls.primaryName': 'v1', }, }); - app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - connectSrc: ["'self'", 'https://cdn.jsdelivr.net'], - styleSrc: [ - "'self'", - "'unsafe-inline'", - 'https://fonts.googleapis.com', - ], - fontSrc: ["'self'", 'https://fonts.gstatic.com'], - imgSrc: ["'self'", 'data:'], - scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], - }, - }, - }), - ); await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/src/v1/auth/auth.module.ts b/src/v1/auth/auth.module.ts index 737976a..fe774af 100644 --- a/src/v1/auth/auth.module.ts +++ b/src/v1/auth/auth.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ConfigModule } from '@nestjs/config'; import { PassportModule } from '@nestjs/passport'; -import { JwtModule } from '@nestjs/jwt'; import { JwtAuthGuard } from './guards/jwt.auth.guard'; import { SupabaseStrategy } from './strategies/supabase.strategy'; import { AuthController } from './auth.controller'; @@ -9,23 +8,9 @@ import { AuthService } from './auth.service'; import { SupabaseModule } from '../../supabase/supabase.module'; @Module({ - imports: [ - PassportModule, - ConfigModule, - SupabaseModule, - JwtModule.registerAsync({ - useFactory: (configService: ConfigService) => { - return { - global: true, - secret: configService.get('PRIVATE_SUPABASE_JWT_SECRET'), - signOptions: { expiresIn: 40000 }, - }; - }, - inject: [ConfigService], - }), - ], + imports: [PassportModule, ConfigModule, SupabaseModule], providers: [JwtAuthGuard, SupabaseStrategy, AuthService], - exports: [JwtAuthGuard, JwtModule], + exports: [JwtAuthGuard], controllers: [AuthController], }) export class AuthModule {} diff --git a/src/v1/auth/strategies/supabase.strategy.spec.ts b/src/v1/auth/strategies/supabase.strategy.spec.ts new file mode 100644 index 0000000..be61f7c --- /dev/null +++ b/src/v1/auth/strategies/supabase.strategy.spec.ts @@ -0,0 +1,76 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { SupabaseStrategy } from './supabase.strategy'; + +const VALID_SUB = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + +function makeConfig( + overrides: Record = {}, +): ConfigService { + const values: Record = { + PRIVATE_SUPABASE_JWT_SECRET: 'test-secret', + PRIVATE_SUPABASE_URL: 'https://proj.supabase.co', + ...overrides, + }; + return { get: (key: string) => values[key] } as unknown as ConfigService; +} + +describe('SupabaseStrategy', () => { + describe('constructor', () => { + it('throws when the JWT secret is not configured', () => { + expect( + () => + new SupabaseStrategy( + makeConfig({ PRIVATE_SUPABASE_JWT_SECRET: undefined }), + ), + ).toThrow('PRIVATE_SUPABASE_JWT_SECRET is not configured'); + }); + + it('throws when the Supabase URL is not configured', () => { + expect( + () => + new SupabaseStrategy(makeConfig({ PRIVATE_SUPABASE_URL: undefined })), + ).toThrow('PRIVATE_SUPABASE_URL is not configured'); + }); + }); + + describe('validate', () => { + const strategy = new SupabaseStrategy(makeConfig()); + + it('returns the authenticated user for a valid UUID sub', () => { + expect( + strategy.validate({ sub: VALID_SUB, email: 'User@Example.com' }), + ).toEqual({ + sub: VALID_SUB, + email: 'user@example.com', + isStaff: false, + }); + }); + + it('flags @cropwatch.io callers as staff', () => { + expect( + strategy.validate({ sub: VALID_SUB, email: 'ops@cropwatch.io' }), + ).toMatchObject({ isStaff: true }); + }); + + it('trims surrounding whitespace before validating the sub', () => { + expect( + strategy.validate({ sub: ` ${VALID_SUB} `, email: null }), + ).toMatchObject({ sub: VALID_SUB }); + }); + + it('rejects a non-UUID sub', () => { + expect(() => strategy.validate({ sub: 'not-a-uuid' })).toThrow( + UnauthorizedException, + ); + }); + + it('rejects a missing sub', () => { + expect(() => strategy.validate({})).toThrow(UnauthorizedException); + }); + + it('rejects a null payload', () => { + expect(() => strategy.validate(null)).toThrow(UnauthorizedException); + }); + }); +}); diff --git a/src/v1/auth/strategies/supabase.strategy.ts b/src/v1/auth/strategies/supabase.strategy.ts index 8a3abd9..5a387b9 100644 --- a/src/v1/auth/strategies/supabase.strategy.ts +++ b/src/v1/auth/strategies/supabase.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { isUUID } from 'class-validator'; import { isStaffEmail } from '../../common/owner-filter.helper'; import type { AuthenticatedUser } from '../authenticated-user'; @@ -12,10 +13,25 @@ export class SupabaseStrategy extends PassportStrategy(Strategy) { if (!secret) { throw new Error('PRIVATE_SUPABASE_JWT_SECRET is not configured'); } + const supabaseUrl = configService.get('PRIVATE_SUPABASE_URL'); + if (!supabaseUrl) { + throw new Error('PRIVATE_SUPABASE_URL is not configured'); + } super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: secret, + // Pin verification to exactly the tokens Supabase GoTrue issues for this + // project. Without these, any HS* token signed with the shared secret — + // whatever its issuer or audience — would be accepted (the project's own + // anon/service_role keys are not JWTs under the new sb_ format, but a + // token minted elsewhere with the same secret would otherwise pass). + // NOTE: Supabase currently signs user tokens with HS256 (symmetric). If the + // project later moves to asymmetric JWT signing keys, swap `secretOrKey` + // for a JWKS `secretOrKeyProvider` and update `algorithms` accordingly. + algorithms: ['HS256'], + issuer: `${supabaseUrl.replace(/\/+$/, '')}/auth/v1`, + audience: 'authenticated', }); } @@ -28,7 +44,10 @@ export class SupabaseStrategy extends PassportStrategy(Strategy) { const claims = (payload ?? {}) as Record; const sub = typeof claims.sub === 'string' ? claims.sub.trim() : ''; - if (!sub) { + // `sub` is interpolated into PostgREST `.or(...)` filter strings across the + // data services, so require a real UUID (the shape Supabase always issues) + // rather than merely non-empty — this closes that string-injection surface. + if (!isUUID(sub)) { throw new UnauthorizedException('Invalid bearer token'); } diff --git a/src/v1/common/guards/user-throttler.guard.spec.ts b/src/v1/common/guards/user-throttler.guard.spec.ts new file mode 100644 index 0000000..4037e51 --- /dev/null +++ b/src/v1/common/guards/user-throttler.guard.spec.ts @@ -0,0 +1,54 @@ +import { createHash } from 'crypto'; +import type { Request } from 'express'; +import { UserThrottlerGuard } from './user-throttler.guard'; + +type Trackable = { getTracker(req: Request): Promise }; + +// getTracker reads only the request (no `this`/DI), so bypass the +// ThrottlerGuard constructor and call the method directly. +const guard = Object.create(UserThrottlerGuard.prototype) as UserThrottlerGuard; +const track = (req: Partial): Promise => + (guard as unknown as Trackable).getTracker(req as Request); + +const sha256 = (value: string) => + createHash('sha256').update(value).digest('hex'); + +describe('UserThrottlerGuard.getTracker', () => { + it('keys a bearer request on the sha256 of the token', async () => { + const req = { + headers: { authorization: 'Bearer abc.def.ghi' }, + ip: '9.9.9.9', + }; + await expect(track(req)).resolves.toBe(`user:${sha256('abc.def.ghi')}`); + }); + + it('gives identical tokens the same key and different tokens different keys', async () => { + const a1 = await track({ headers: { authorization: 'Bearer tok-a' } }); + const a2 = await track({ headers: { authorization: 'Bearer tok-a' } }); + const b = await track({ headers: { authorization: 'Bearer tok-b' } }); + expect(a1).toBe(a2); + expect(a1).not.toBe(b); + }); + + it('is case-insensitive on the Bearer scheme and trims whitespace', async () => { + await expect( + track({ headers: { authorization: ' bearer tok-a' } }), + ).resolves.toBe(`user:${sha256('tok-a')}`); + }); + + it('falls back to the client IP when there is no bearer token', async () => { + await expect(track({ headers: {}, ip: '203.0.113.7' })).resolves.toBe( + 'ip:203.0.113.7', + ); + }); + + it('falls back to the client IP for a non-Bearer Authorization header', async () => { + await expect( + track({ headers: { authorization: 'Basic Zm9v' }, ip: '203.0.113.7' }), + ).resolves.toBe('ip:203.0.113.7'); + }); + + it('returns a stable placeholder when neither token nor IP is present', async () => { + await expect(track({ headers: {} })).resolves.toBe('ip:unknown'); + }); +}); diff --git a/src/v1/common/guards/user-throttler.guard.ts b/src/v1/common/guards/user-throttler.guard.ts new file mode 100644 index 0000000..f47ed17 --- /dev/null +++ b/src/v1/common/guards/user-throttler.guard.ts @@ -0,0 +1,43 @@ +import { createHash } from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import type { Request } from 'express'; + +/** + * Rate-limit tracker keyed per authenticated user instead of per IP. + * + * Why: the web app renders server-side on Vercel, so *every* user's SSR + * requests egress from a small shared pool of Vercel IPs. A per-IP limit tight + * enough to catch abuse would throttle the whole app's SSR for everyone. Keying + * on the bearer token gives each user their own bucket, isolated from other + * users' traffic and from the shared egress IP. + * + * We key on a hash of the raw token (not the decoded `sub`) so that: + * - an attacker cannot burn a victim's bucket by forging a token carrying the + * victim's `sub` — they would need the victim's actual signed token, which + * the guard never sees; and + * - the token is never stored as a plaintext key in the throttler store. + * + * Requests without a bearer token (the landing page; webhooks, which are + * `@SkipThrottle`-d; login, which has its own tighter `@Throttle`) fall back to + * the client IP — now authentic because `trust proxy` is pinned to a single hop + * in main.ts, so `req.ip` is the address Vercel appended rather than a spoofable + * left-most X-Forwarded-For entry. + */ +@Injectable() +export class UserThrottlerGuard extends ThrottlerGuard { + protected getTracker(req: Request): Promise { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string') { + const match = /^Bearer\s+(.+)$/i.exec(authHeader.trim()); + if (match) { + const digest = createHash('sha256').update(match[1]).digest('hex'); + return Promise.resolve(`user:${digest}`); + } + } + const ip = req.ip; + return Promise.resolve( + typeof ip === 'string' && ip.length > 0 ? `ip:${ip}` : 'ip:unknown', + ); + } +} diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 3586abc..b179874 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -3,7 +3,7 @@ import { Body, Controller, Get, - InternalServerErrorException, + NotImplementedException, Param, Patch, Post, @@ -462,31 +462,20 @@ export class DevicesController { Please contact support if you would like this feature to be prioritized. `, }) - async replace( - @CurrentUser() user: AuthenticatedUser, - @Param('dev_eui') devEui: string, - @Body() body: ReplaceDeviceDto, - ) { - const normalizedDevEui = devEui?.trim(); - if (body.dev_eui?.trim() && body.dev_eui.trim() !== normalizedDevEui) { - throw new BadRequestException( - 'dev_eui in body must match route parameter', - ); - } - - const replacementDevice: ReplaceDeviceDto = body; - - const insertResult = await this.devicesService.replaceDevice( - user, - normalizedDevEui, - replacementDevice, + replace( + @CurrentUser() _user: AuthenticatedUser, + @Param('dev_eui') _devEui: string, + @Body() _body: ReplaceDeviceDto, + ): never { + // Device replacement is intentionally disabled. Re-keying a device's dev_eui + // requires atomically re-pointing 16 child tables whose FKs lack + // ON UPDATE CASCADE, plus a corrected authorization + uniqueness design + // (tracked as a separate implementation pass). Until that lands, fail closed + // with 501 rather than run the previous unauthorized / FK-violating path. + // Params are kept (with `_` prefix) so the OpenAPI contract is unchanged. + throw new NotImplementedException( + 'Device replacement is not yet implemented. Please contact support if you would like this feature to be prioritized.', ); - if (!insertResult) { - throw new InternalServerErrorException( - 'Device replacement is not yet implemented. Please contact support if you would like this feature to be prioritized.', - ); - } - return insertResult; } @Patch(':dev_eui/permission-level') diff --git a/src/v1/devices/devices.service.spec.ts b/src/v1/devices/devices.service.spec.ts index c1cf415..9b2011b 100644 --- a/src/v1/devices/devices.service.spec.ts +++ b/src/v1/devices/devices.service.spec.ts @@ -608,4 +608,111 @@ describe('DevicesService', () => { ); }); }); + + describe('replaceDevice', () => { + const admin = { + sub: 'admin-1', + email: 'admin@example.com', + isStaff: false, + }; + + // ADMIN-scope device lookup: .select().eq().eq().lte().or().single(). + function createDeviceScopeBuilder(deviceRow: unknown) { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + lte: jest.fn().mockReturnThis(), + or: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ data: deviceRow, error: null }), + }; + } + + // Update path: .update().eq().select('*').single(). + function createUpdateBuilder(row: unknown) { + return { + update: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ data: row, error: null }), + }; + } + + function createService(builders: unknown[]) { + const fromMock = jest.fn(); + for (const builder of builders) { + fromMock.mockImplementationOnce(() => builder); + } + const supabaseService = { + getClient: jest.fn(() => ({ from: fromMock })), + getAdminClient: jest.fn(), + }; + return { + service: new DevicesService( + supabaseService as unknown as SupabaseService, + {} as any, + ), + fromMock, + }; + } + + it('authorizes against the replacement dev_eui, not the old one', async () => { + const existingBuilder = createDeviceScopeBuilder({ dev_eui: 'OLD-EUI' }); + const newDeviceBuilder = createDeviceScopeBuilder({ dev_eui: 'NEW-EUI' }); + const updateBuilder = createUpdateBuilder({ dev_eui: 'NEW-EUI' }); + const { service, fromMock } = createService([ + existingBuilder, + newDeviceBuilder, + updateBuilder, + ]); + + await service.replaceDevice(admin, 'OLD-EUI', { dev_eui: 'NEW-EUI' }); + + // The old-device check still runs against the route eui... + expect(existingBuilder.eq).toHaveBeenCalledWith('dev_eui', 'OLD-EUI'); + // ...and the replacement check must target the NEW eui. The bug was that + // it re-checked the old eui, so no authz ever ran against the target. + expect(newDeviceBuilder.eq).toHaveBeenCalledWith('dev_eui', 'NEW-EUI'); + expect(newDeviceBuilder.eq).not.toHaveBeenCalledWith( + 'dev_eui', + 'OLD-EUI', + ); + expect(fromMock).toHaveBeenCalledTimes(3); + }); + + it('does not update when the caller lacks access to the replacement device', async () => { + const existingBuilder = createDeviceScopeBuilder({ dev_eui: 'OLD-EUI' }); + const newDeviceBuilder = createDeviceScopeBuilder(null); // no access to NEW + const updateBuilder = createUpdateBuilder({ dev_eui: 'NEW-EUI' }); + const { service, fromMock } = createService([ + existingBuilder, + newDeviceBuilder, + updateBuilder, + ]); + + await expect( + service.replaceDevice(admin, 'OLD-EUI', { dev_eui: 'NEW-EUI' }), + ).rejects.toMatchObject({ status: 404 }); + + expect(newDeviceBuilder.eq).toHaveBeenCalledWith('dev_eui', 'NEW-EUI'); + // The device update must never run. + expect(updateBuilder.update).not.toHaveBeenCalled(); + expect(fromMock).toHaveBeenCalledTimes(2); + }); + + it('rejects a blank replacement dev_eui before any replacement lookup', async () => { + const existingBuilder = createDeviceScopeBuilder({ dev_eui: 'OLD-EUI' }); + const updateBuilder = createUpdateBuilder({ dev_eui: 'NEW-EUI' }); + const { service, fromMock } = createService([ + existingBuilder, + updateBuilder, + ]); + + await expect( + service.replaceDevice(admin, 'OLD-EUI', { dev_eui: ' ' }), + ).rejects.toMatchObject({ status: 400 }); + + // Only the existing-device lookup ran; no replacement lookup, no update. + expect(fromMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index 6095f3a..f83c26d 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -964,11 +964,18 @@ export class DevicesService { throw new NotFoundException('Device not found'); } - // We have access to the existing device, lets ensure we have access to the new device. + // We have access to the existing device; verify access to the REPLACEMENT + // device by its own dev_eui. Previously this re-checked the old eui (a + // copy-paste of the block above), so no authorization was ever performed + // against the target device. + const normalizedNewDevEui = newDevice.dev_eui?.trim(); + if (!normalizedNewDevEui) { + throw new BadRequestException('Replacement dev_eui is required'); + } let newDeviceQuery = client .from('cw_devices') .select(`*, owner_match:cw_device_owners()`) - .eq('dev_eui', normalizedDevEui); + .eq('dev_eui', normalizedNewDevEui); newDeviceQuery = this.applyDeviceManageScope( newDeviceQuery, diff --git a/src/v1/line/line.controller.ts b/src/v1/line/line.controller.ts index 7989135..e951c6d 100644 --- a/src/v1/line/line.controller.ts +++ b/src/v1/line/line.controller.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/swagger'; import type { RawBodyRequest } from '@nestjs/common'; import type { Request } from 'express'; +import { SkipThrottle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @@ -33,6 +34,10 @@ import { export class LineController { constructor(private readonly lineService: LineService) {} + // Signature-verified in the service and delivered by LINE's own retrying + // webhook from a small set of provider IPs — exempt from the per-user/IP + // throttler so a redelivery burst can't be dropped with a 429. + @SkipThrottle() @Post('webhook') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index d0abda9..37f8d15 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -20,6 +20,7 @@ import { ApiParam, ApiSecurity, } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @@ -169,6 +170,10 @@ export class PaymentsController { ); } + // Signature-verified in the service and driven by Stripe's own retrying + // delivery from a small set of provider IPs — exempt from the per-user/IP + // throttler so a retry burst can't get billing events dropped with a 429. + @SkipThrottle() @Post('webhook') @HttpCode(HttpStatus.ACCEPTED) @ApiOperation({ diff --git a/src/v1/push/dto/register-push-token.dto.ts b/src/v1/push/dto/register-push-token.dto.ts new file mode 100644 index 0000000..3fdf39f --- /dev/null +++ b/src/v1/push/dto/register-push-token.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class RegisterPushTokenDto { + @ApiProperty({ description: 'FCM registration token for this device' }) + @IsString() + @IsNotEmpty() + @MaxLength(512) + token: string; + + @ApiProperty({ + required: false, + description: 'Human-readable label for the enrolled device', + }) + @IsOptional() + @IsString() + @MaxLength(120) + deviceLabel?: string; +} diff --git a/src/v1/push/push.controller.spec.ts b/src/v1/push/push.controller.spec.ts new file mode 100644 index 0000000..a73e3e6 --- /dev/null +++ b/src/v1/push/push.controller.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; +import { PushController } from './push.controller'; +import type { PushService } from './push.service'; + +const USER = { sub: 'user-1', email: 'kevin@example.com', isStaff: false }; + +function createController() { + const service = { + registerToken: jest.fn(() => Promise.resolve()), + unregisterToken: jest.fn(() => Promise.resolve()), + listTokens: jest.fn(() => Promise.resolve([])), + listEligibleRecipients: jest.fn(() => Promise.resolve([])), + }; + return { + controller: new PushController(service as unknown as PushService), + service, + }; +} + +describe('PushController', () => { + it('registers a token for the current user', async () => { + const { controller, service } = createController(); + + await expect( + controller.registerToken(USER, { + token: 'fcm-token-1', + deviceLabel: 'Pixel 9', + }), + ).resolves.toEqual({ registered: true }); + + expect(service.registerToken).toHaveBeenCalledWith( + 'user-1', + 'fcm-token-1', + 'Pixel 9', + ); + }); + + it('unregisters a trimmed token for the current user', async () => { + const { controller, service } = createController(); + + await controller.unregisterToken(USER, ' fcm-token-1 '); + + expect(service.unregisterToken).toHaveBeenCalledWith( + 'user-1', + 'fcm-token-1', + ); + }); + + it('rejects unregister without a token', async () => { + const { controller, service } = createController(); + + await expect(controller.unregisterToken(USER, ' ')).rejects.toThrow( + BadRequestException, + ); + expect(service.unregisterToken).not.toHaveBeenCalled(); + }); + + it('parses comma-separated devEuis for recipients', async () => { + const { controller, service } = createController(); + + await controller.listRecipients(USER, ' DEV-A , DEV-B ,, '); + + expect(service.listEligibleRecipients).toHaveBeenCalledWith(USER, [ + 'DEV-A', + 'DEV-B', + ]); + }); + + it('rejects recipients without devEuis', () => { + const { controller, service } = createController(); + + expect(() => controller.listRecipients(USER, '')).toThrow( + BadRequestException, + ); + expect(service.listEligibleRecipients).not.toHaveBeenCalled(); + }); +}); diff --git a/src/v1/push/push.controller.ts b/src/v1/push/push.controller.ts new file mode 100644 index 0000000..ac7c531 --- /dev/null +++ b/src/v1/push/push.controller.ts @@ -0,0 +1,105 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; +import { + PushService, + type PushRecipientCandidate, + type PushTokenSummary, +} from './push.service'; +import { RegisterPushTokenDto } from './dto/register-push-token.dto'; + +@ApiTags('push') +@Controller({ path: 'push', version: '1' }) +export class PushController { + constructor(private readonly pushService: PushService) {} + + @Post('tokens') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Register (or refresh) an FCM push token for this device', + }) + async registerToken( + @CurrentUser() user: AuthenticatedUser, + @Body() body: RegisterPushTokenDto, + ): Promise<{ registered: boolean }> { + await this.pushService.registerToken(user.sub, body.token, body.deviceLabel); + return { registered: true }; + } + + @Delete('tokens') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Unregister an FCM push token for this device' }) + @ApiQuery({ + name: 'token', + required: true, + type: String, + description: 'FCM registration token to remove', + }) + async unregisterToken( + @CurrentUser() user: AuthenticatedUser, + @Query('token') token?: string, + ): Promise { + const trimmed = (token ?? '').trim(); + if (trimmed.length === 0) { + throw new BadRequestException('token is required'); + } + await this.pushService.unregisterToken(user.sub, trimmed); + } + + @Get('tokens') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "List the current user's registered push tokens" }) + listTokens(@CurrentUser() user: AuthenticatedUser): Promise< + PushTokenSummary[] + > { + return this.pushService.listTokens(user.sub); + } + + @Get('recipients') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'List users eligible as push recipients for the given devices', + }) + @ApiQuery({ + name: 'devEuis', + required: true, + type: String, + description: 'Comma-separated device EUIs', + }) + listRecipients( + @CurrentUser() user: AuthenticatedUser, + @Query('devEuis') devEuis?: string, + ): Promise { + const parsed = (devEuis ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (parsed.length === 0) { + throw new BadRequestException('devEuis is required'); + } + return this.pushService.listEligibleRecipients(user, parsed); + } +} diff --git a/src/v1/push/push.module.ts b/src/v1/push/push.module.ts new file mode 100644 index 0000000..59fac27 --- /dev/null +++ b/src/v1/push/push.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupabaseModule } from '../../supabase/supabase.module'; +import { PushController } from './push.controller'; +import { PushService } from './push.service'; + +@Module({ + imports: [SupabaseModule], + controllers: [PushController], + providers: [PushService], + exports: [PushService], +}) +export class PushModule {} diff --git a/src/v1/push/push.service.spec.ts b/src/v1/push/push.service.spec.ts new file mode 100644 index 0000000..3c08146 --- /dev/null +++ b/src/v1/push/push.service.spec.ts @@ -0,0 +1,289 @@ +import { SupabaseService } from '../../supabase/supabase.service'; +import { PushService } from './push.service'; + +type StubResult = { + data: unknown; + error: { message: string; code?: string } | null; +}; + +// Chainable, thenable query stub: filter methods record args and return the +// chain; awaiting resolves the configured result. Mirrors line.service.spec.ts +// with upsert added. +function chain(result: StubResult) { + const calls: Array<{ method: string; args: unknown[] }> = []; + const stub: Record = { calls }; + for (const method of [ + 'select', + 'insert', + 'upsert', + 'update', + 'delete', + 'eq', + 'gt', + 'lt', + 'in', + ]) { + stub[method] = jest.fn((...args: unknown[]) => { + calls.push({ method, args }); + return stub; + }); + } + stub.maybeSingle = jest.fn(() => Promise.resolve(result)); + stub.then = (resolve: (value: StubResult) => unknown) => resolve(result); + return stub as Record & { + calls: Array<{ method: string; args: unknown[] }>; + }; +} + +function buildAdminClient( + stubsByTable: Record[]>, +) { + const queues = new Map( + Object.entries(stubsByTable).map(([k, v]) => [k, [...v]]), + ); + const from = jest.fn((table: string) => { + const queue = queues.get(table); + if (!queue || queue.length === 0) { + throw new Error(`Unexpected from('${table}')`); + } + return queue.length > 1 ? queue.shift()! : queue[0]; + }); + return { from }; +} + +function createService(adminClient?: { from: jest.Mock }) { + return new PushService({ + getAdminClient: jest.fn(() => adminClient ?? { from: jest.fn() }), + } as unknown as SupabaseService); +} + +describe('PushService', () => { + describe('registerToken', () => { + it('upserts on token with the caller as owner and a fresh last_seen_at', async () => { + const upsert = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ cw_push_tokens: [upsert] }); + const service = createService(adminClient); + + await service.registerToken('user-1', 'fcm-token-1', 'Pixel 9'); + + const call = upsert.calls.find((c) => c.method === 'upsert'); + expect(call).toBeDefined(); + const [row, options] = call!.args as [ + Record, + Record, + ]; + expect(row.token).toBe('fcm-token-1'); + expect(row.user_id).toBe('user-1'); + expect(row.device_label).toBe('Pixel 9'); + expect(typeof row.last_seen_at).toBe('string'); + expect(options).toEqual({ onConflict: 'token' }); + }); + + it('stores a null device_label when none is provided', async () => { + const upsert = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ cw_push_tokens: [upsert] }); + const service = createService(adminClient); + + await service.registerToken('user-1', 'fcm-token-1'); + + const call = upsert.calls.find((c) => c.method === 'upsert'); + const [row] = call!.args as [Record]; + expect(row.device_label).toBeNull(); + }); + + it('throws when the upsert fails', async () => { + const upsert = chain({ data: null, error: { message: 'boom' } }); + const adminClient = buildAdminClient({ cw_push_tokens: [upsert] }); + const service = createService(adminClient); + + await expect( + service.registerToken('user-1', 'fcm-token-1'), + ).rejects.toThrow('Failed to register push token'); + }); + }); + + describe('unregisterToken', () => { + it('deletes only the caller-owned row for the token', async () => { + const del = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ cw_push_tokens: [del] }); + const service = createService(adminClient); + + await service.unregisterToken('user-1', 'fcm-token-1'); + + expect(del.calls).toContainEqual({ method: 'delete', args: [] }); + expect(del.calls).toContainEqual({ + method: 'eq', + args: ['token', 'fcm-token-1'], + }); + expect(del.calls).toContainEqual({ + method: 'eq', + args: ['user_id', 'user-1'], + }); + }); + }); + + describe('listTokens', () => { + it("returns only the caller's rows, camel-cased", async () => { + const select = chain({ + data: [ + { + token: 'fcm-token-1', + device_label: 'Pixel 9', + created_at: '2026-08-01T00:00:00Z', + last_seen_at: '2026-08-20T00:00:00Z', + }, + ], + error: null, + }); + const adminClient = buildAdminClient({ cw_push_tokens: [select] }); + const service = createService(adminClient); + + const result = await service.listTokens('user-1'); + + expect(select.calls).toContainEqual({ + method: 'eq', + args: ['user_id', 'user-1'], + }); + expect(result).toEqual([ + { + token: 'fcm-token-1', + deviceLabel: 'Pixel 9', + createdAt: '2026-08-01T00:00:00Z', + lastSeenAt: '2026-08-20T00:00:00Z', + }, + ]); + }); + }); + + describe('listEligibleRecipients', () => { + const caller = { + sub: 'user-1', + email: 'kevin@example.com', + isStaff: false, + }; + + it('scopes to caller-viewable devices, includes the owner, excludes DISABLED, and maps pushEnabled', async () => { + const managedLookup = chain({ + data: [ + { + dev_eui: 'DEV-A', + name: 'A', + user_id: 'user-1', + cw_device_owners: [], + }, + ], + error: null, + }); + const viewersLookup = chain({ + data: [ + { + user_id: 'owner-9', + cw_device_owners: [ + { user_id: 'viewer-4', permission_level: 4 }, + { user_id: 'disabled-5', permission_level: 5 }, + ], + }, + ], + error: null, + }); + const profilesLookup = chain({ + data: [ + { + id: 'owner-9', + full_name: 'Zoe Owner', + username: null, + email: null, + }, + { + id: 'viewer-4', + full_name: null, + username: null, + email: 'v4@example.com', + }, + ], + error: null, + }); + const tokensLookup = chain({ + data: [{ user_id: 'owner-9' }], + error: null, + }); + const adminClient = buildAdminClient({ + cw_devices: [managedLookup, viewersLookup], + profiles: [profilesLookup], + cw_push_tokens: [tokensLookup], + }); + const service = createService(adminClient); + + const result = await service.listEligibleRecipients(caller, [ + 'DEV-A', + 'DEV-NOT-VISIBLE', + ]); + + // Scoped viewers query only includes the viewable device. + expect(viewersLookup.calls).toContainEqual({ + method: 'in', + args: ['dev_eui', ['DEV-A']], + }); + // Sorted by display name; owner included; DISABLED excluded; fallback + // chain and pushEnabled flags applied. + expect(result).toEqual([ + { + userId: 'viewer-4', + displayName: 'v4@example.com', + pushEnabled: false, + }, + { userId: 'owner-9', displayName: 'Zoe Owner', pushEnabled: true }, + ]); + }); + + it('returns empty without further queries when the caller can view none', async () => { + const managedLookup = chain({ data: [], error: null }); + const adminClient = buildAdminClient({ cw_devices: [managedLookup] }); + const service = createService(adminClient); + + await expect( + service.listEligibleRecipients(caller, ['DEV-X']), + ).resolves.toEqual([]); + }); + + it('marks a user enrolled with multiple tokens as pushEnabled once', async () => { + const managedLookup = chain({ + data: [ + { + dev_eui: 'DEV-A', + name: 'A', + user_id: 'user-1', + cw_device_owners: [], + }, + ], + error: null, + }); + const viewersLookup = chain({ + data: [{ user_id: 'multi-user', cw_device_owners: [] }], + error: null, + }); + const profilesLookup = chain({ + data: [ + { id: 'multi-user', full_name: 'Multi', username: null, email: null }, + ], + error: null, + }); + const tokensLookup = chain({ + data: [{ user_id: 'multi-user' }, { user_id: 'multi-user' }], + error: null, + }); + const adminClient = buildAdminClient({ + cw_devices: [managedLookup, viewersLookup], + profiles: [profilesLookup], + cw_push_tokens: [tokensLookup], + }); + const service = createService(adminClient); + + const result = await service.listEligibleRecipients(caller, ['DEV-A']); + + expect(result).toEqual([ + { userId: 'multi-user', displayName: 'Multi', pushEnabled: true }, + ]); + }); + }); +}); diff --git a/src/v1/push/push.service.ts b/src/v1/push/push.service.ts new file mode 100644 index 0000000..fb2aea6 --- /dev/null +++ b/src/v1/push/push.service.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@nestjs/common'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { listManagedDevices } from '../common/managed-devices.helper'; +import { canRead } from '../common/permission-levels'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; + +export interface PushRecipientCandidate { + userId: string; + displayName: string; + pushEnabled: boolean; +} + +export interface PushTokenSummary { + token: string; + deviceLabel: string | null; + createdAt: string; + lastSeenAt: string; +} + +@Injectable() +export class PushService { + constructor(private readonly supabaseService: SupabaseService) {} + + // Idempotent: re-registering after a page reload or FCM token refresh + // re-stamps last_seen_at; a different account on the same browser takes + // the token over (a token addresses one browser profile). + async registerToken( + userId: string, + token: string, + deviceLabel?: string, + ): Promise { + const { error } = await this.supabaseService + .getAdminClient() + .from('cw_push_tokens') + .upsert( + { + token, + user_id: userId, + device_label: deviceLabel ?? null, + last_seen_at: new Date().toISOString(), + }, + { onConflict: 'token' }, + ); + + if (error) { + throw new Error(`Failed to register push token: ${error.message}`); + } + } + + async unregisterToken(userId: string, token: string): Promise { + const { error } = await this.supabaseService + .getAdminClient() + .from('cw_push_tokens') + .delete() + .eq('token', token) + .eq('user_id', userId); + + if (error) { + throw new Error(`Failed to unregister push token: ${error.message}`); + } + } + + async listTokens(userId: string): Promise { + const { data, error } = await this.supabaseService + .getAdminClient() + .from('cw_push_tokens') + .select('token, device_label, created_at, last_seen_at') + .eq('user_id', userId); + + if (error) { + throw new Error(`Failed to list push tokens: ${error.message}`); + } + + return ( + (data ?? []) as Array<{ + token: string; + device_label: string | null; + created_at: string; + last_seen_at: string; + }> + ).map((row) => ({ + token: row.token, + deviceLabel: row.device_label, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + })); + } + + // Users eligible as push recipients for a rule: everyone with view access + // to any of the given devices, scoped to devices the CALLER can view. + // Users without a registered token are included (flagged) — they start + // receiving alerts the moment they enroll a device. + async listEligibleRecipients( + user: AuthenticatedUser, + devEuis: string[], + ): Promise { + const client = this.supabaseService.getAdminClient(); + + const managed = await listManagedDevices(client, user.sub, user.isStaff); + const viewable = new Set( + managed.filter((device) => device.canView).map((device) => device.devEui), + ); + const scoped = devEuis.filter((devEui) => viewable.has(devEui)); + if (scoped.length === 0) return []; + + const { data: devices, error: devicesError } = await client + .from('cw_devices') + .select('user_id, cw_device_owners(user_id, permission_level)') + .in('dev_eui', scoped); + + if (devicesError) { + throw new Error(`Failed to load device viewers: ${devicesError.message}`); + } + + const viewerIds = new Set(); + for (const device of (devices ?? []) as Array<{ + user_id: string | null; + cw_device_owners?: Array<{ + user_id: string | null; + permission_level: number | null; + }> | null; + }>) { + if (device.user_id) viewerIds.add(device.user_id); + for (const owner of device.cw_device_owners ?? []) { + if (owner.user_id && canRead(owner.permission_level)) { + viewerIds.add(owner.user_id); + } + } + } + if (viewerIds.size === 0) return []; + + const { data: profiles, error: profilesError } = await client + .from('profiles') + .select('id, full_name, username, email') + .in('id', [...viewerIds]); + + if (profilesError) { + throw new Error(`Failed to load profiles: ${profilesError.message}`); + } + + const { data: tokenRows, error: tokensError } = await client + .from('cw_push_tokens') + .select('user_id') + .in('user_id', [...viewerIds]); + + if (tokensError) { + throw new Error(`Failed to load push tokens: ${tokensError.message}`); + } + + const enrolled = new Set( + ((tokenRows ?? []) as Array<{ user_id: string }>).map( + (row) => row.user_id, + ), + ); + + return ( + (profiles ?? []) as Array<{ + id: string; + full_name: string | null; + username: string | null; + email: string | null; + }> + ) + .map((profile) => ({ + userId: profile.id, + displayName: + profile.full_name ?? profile.username ?? profile.email ?? profile.id, + pushEnabled: enrolled.has(profile.id), + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + } +} diff --git a/src/v1/rules/dto/rule-catalog.dto.ts b/src/v1/rules/dto/rule-catalog.dto.ts new file mode 100644 index 0000000..68119ac --- /dev/null +++ b/src/v1/rules/dto/rule-catalog.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RuleCatalogDeviceDto { + @ApiProperty() + devEui: string; + + @ApiProperty({ nullable: true }) + name: string | null; +} + +export class RuleCatalogEntryDto { + @ApiProperty() + templateId: number; + + @ApiProperty() + name: string; + + @ApiProperty({ type: RuleCatalogDeviceDto, isArray: true }) + devices: RuleCatalogDeviceDto[]; +} + +export class RuleCatalogDto { + @ApiProperty({ + type: RuleCatalogEntryDto, + isArray: true, + description: + 'Active rule templates the caller can see, each with its assigned devices. Trimmed for low-power clients — no criteria, actions, or state.', + }) + rules: RuleCatalogEntryDto[]; +} diff --git a/src/v1/rules/dto/rule-device-state.dto.ts b/src/v1/rules/dto/rule-device-state.dto.ts new file mode 100644 index 0000000..6ccad45 --- /dev/null +++ b/src/v1/rules/dto/rule-device-state.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RuleDeviceStateEntryDto { + @ApiProperty() + devEui: string; + + @ApiProperty() + templateId: number; + + @ApiProperty() + isTriggered: boolean; + + @ApiProperty({ + nullable: true, + format: 'date-time', + description: + 'Most recent of last_triggered_at / last_reset_at for this pair.', + }) + lastChange: string | null; +} + +export class RuleDeviceStateDto { + @ApiProperty({ + format: 'date-time', + description: + 'Server time at response build, so low-power clients can sanity-check their clock.', + }) + ts: string; + + @ApiProperty({ + type: RuleDeviceStateEntryDto, + isArray: true, + description: + 'Only pairs that have a state row are returned. cw_rule_state rows are created lazily on first trigger, so a requested device absent from this list has never triggered and must be treated as isTriggered = false.', + }) + states: RuleDeviceStateEntryDto[]; +} diff --git a/src/v1/rules/rules.controller.spec.ts b/src/v1/rules/rules.controller.spec.ts index d725619..47b08e1 100644 --- a/src/v1/rules/rules.controller.spec.ts +++ b/src/v1/rules/rules.controller.spec.ts @@ -25,6 +25,51 @@ describe('RulesController', () => { expect(controller).toBeDefined(); }); + it('getStateForDevices accepts repeated and comma-separated dev_eui params', async () => { + const getStateForDevices = jest.fn().mockResolvedValue({ + ts: '2026-08-11T00:00:00Z', + states: [], + }); + const module: TestingModule = await Test.createTestingModule({ + controllers: [RulesController], + providers: [ + { provide: RulesService, useValue: { getStateForDevices } }, + ], + }).compile(); + const user = { sub: 'user-1', email: 'user@example.com', isStaff: false }; + + await module + .get(RulesController) + .getStateForDevices(user, ['AA', 'BB,CC', ' DD ', '']); + + expect(getStateForDevices).toHaveBeenCalledWith(user, [ + 'AA', + 'BB', + 'CC', + 'DD', + ]); + }); + + it('getStateForDevices tolerates a missing dev_eui param', async () => { + const getStateForDevices = jest.fn().mockResolvedValue({ + ts: '2026-08-11T00:00:00Z', + states: [], + }); + const module: TestingModule = await Test.createTestingModule({ + controllers: [RulesController], + providers: [ + { provide: RulesService, useValue: { getStateForDevices } }, + ], + }).compile(); + const user = { sub: 'user-1', email: 'user@example.com', isStaff: false }; + + await module + .get(RulesController) + .getStateForDevices(user, undefined); + + expect(getStateForDevices).toHaveBeenCalledWith(user, []); + }); + it('accepts action config under whitelist validation', async () => { const pipe = new ValidationPipe({ forbidNonWhitelisted: true, diff --git a/src/v1/rules/rules.controller.ts b/src/v1/rules/rules.controller.ts index 44fe6d5..e5b04df 100644 --- a/src/v1/rules/rules.controller.ts +++ b/src/v1/rules/rules.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Header, Param, ParseIntPipe, Patch, @@ -19,6 +20,8 @@ import { } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { RuleActionTypeDto } from './dto/rule-action-type.dto'; +import { RuleCatalogDto } from './dto/rule-catalog.dto'; +import { RuleDeviceStateDto } from './dto/rule-device-state.dto'; import { RuleFormContextDto } from './dto/rule-form-context.dto'; import { RuleTemplateDto } from './dto/rule-template.dto'; import { RuleTriggerLogDto } from './dto/rule-trigger-log.dto'; @@ -107,6 +110,41 @@ export class RulesController { findTriggeredCount(@CurrentUser() user: AuthenticatedUser) { return this.rulesService.findTriggeredCount(user); } + @ApiOkResponse({ + description: + 'Active rule templates the caller can see, each with its assigned devices only. Trimmed for low-power provisioning clients (ESP32 bridge portal).', + type: RuleCatalogDto, + }) + @Header('Cache-Control', 'no-store') + @Get('catalog') + getCatalog(@CurrentUser() user: AuthenticatedUser) { + return this.rulesService.getCatalog(user); + } + @ApiOkResponse({ + description: + 'Current rule state for the requested devices, shaped for low-power polling clients. Pairs without a state row have never triggered and are omitted; treat absence as not triggered.', + type: RuleDeviceStateDto, + }) + @ApiQuery({ + name: 'dev_eui', + description: + 'Device EUI to include. Repeatable, or a single comma-separated list.', + required: true, + type: String, + isArray: true, + }) + @Header('Cache-Control', 'no-store') + @Get('state') + getStateForDevices( + @CurrentUser() user: AuthenticatedUser, + @Query('dev_eui') devEui?: string | string[], + ) { + const devEuis = (Array.isArray(devEui) ? devEui : (devEui ? [devEui] : [])) + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return this.rulesService.getStateForDevices(user, devEuis); + } @ApiOkResponse({ description: 'Lists the trigger/reset history for a rule template, newest first.', diff --git a/src/v1/rules/rules.service.spec.ts b/src/v1/rules/rules.service.spec.ts index 9d01998..66f0698 100644 --- a/src/v1/rules/rules.service.spec.ts +++ b/src/v1/rules/rules.service.spec.ts @@ -300,6 +300,96 @@ describe('RulesService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + describe('getStateForDevices', () => { + const jwt = { sub: 'user-1', email: 'user@example.com', isStaff: false }; + + const deviceRows = [ + { dev_eui: 'AA', name: 'Mine', user_id: 'user-1', cw_device_owners: [] }, + { + dev_eui: 'BB', + name: 'Not mine', + user_id: 'someone-else', + cw_device_owners: [], + }, + ]; + + it('returns state rows only for visible requested devices', async () => { + const stateQuery = buildQueryStub({ + list: { + data: [ + { + dev_eui: 'AA', + template_id: 5, + is_triggered: true, + last_triggered_at: '2026-08-11T01:00:00Z', + last_reset_at: '2026-08-10T01:00:00Z', + }, + ], + error: null, + }, + }); + const client = buildClient({ + cw_devices: buildQueryStub({ list: { data: deviceRows, error: null } }), + cw_rule_state: stateQuery, + }); + const service = serviceWith(client); + + const result = await service.getStateForDevices(jwt, ['AA', 'BB']); + + // BB belongs to someone else and must not reach the state query. + expect(stateQuery.in).toHaveBeenCalledWith('dev_eui', ['AA']); + expect(result.states).toEqual([ + { + devEui: 'AA', + templateId: 5, + isTriggered: true, + lastChange: '2026-08-11T01:00:00Z', + }, + ]); + expect(typeof result.ts).toBe('string'); + }); + + it('lastChange is the reset time when the reset is newer', async () => { + const client = buildClient({ + cw_devices: buildQueryStub({ list: { data: deviceRows, error: null } }), + cw_rule_state: buildQueryStub({ + list: { + data: [ + { + dev_eui: 'AA', + template_id: 5, + is_triggered: false, + last_triggered_at: '2026-08-10T01:00:00Z', + last_reset_at: '2026-08-11T02:00:00Z', + }, + ], + error: null, + }, + }), + }); + + const result = await serviceWith(client).getStateForDevices(jwt, ['AA']); + expect(result.states[0].lastChange).toBe('2026-08-11T02:00:00Z'); + }); + + it('skips the state query entirely when nothing requested is visible', async () => { + const client = buildClient({ + cw_devices: buildQueryStub({ list: { data: deviceRows, error: null } }), + // No cw_rule_state stub: from('cw_rule_state') would throw. + }); + + const result = await serviceWith(client).getStateForDevices(jwt, ['BB']); + expect(result.states).toEqual([]); + }); + + it('returns empty without any queries for an empty request', async () => { + const client = buildClient({}); + const result = await serviceWith(client).getStateForDevices(jwt, []); + expect(result.states).toEqual([]); + expect(client.from).not.toHaveBeenCalled(); + }); + }); + describe('triggered rules', () => { const jwt = { sub: 'user-1', email: 'user@example.com', isStaff: false }; diff --git a/src/v1/rules/rules.service.ts b/src/v1/rules/rules.service.ts index 4906925..48f681d 100644 --- a/src/v1/rules/rules.service.ts +++ b/src/v1/rules/rules.service.ts @@ -20,6 +20,8 @@ import { import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; import { RuleActionTypeDto } from './dto/rule-action-type.dto'; +import { RuleCatalogDto } from './dto/rule-catalog.dto'; +import { RuleDeviceStateDto } from './dto/rule-device-state.dto'; import { RuleFormContextDto } from './dto/rule-form-context.dto'; import { RuleTemplateActionDto } from './dto/rule-template-action.dto'; import { RuleTemplateAssignmentDto } from './dto/rule-template-assignment.dto'; @@ -134,6 +136,131 @@ export class RulesService { .filter((rule) => rule.assignments.length > 0); } + /** + * Lean rule catalog for field devices (ESP32 provisioning portal): each + * visible template with its assigned devices, and nothing else — no + * criteria, actions, or state. The full findAll() payload is tens of KB and + * will not fit a microcontroller's buffers; this is a few hundred bytes. + */ + async getCatalog(user: AuthenticatedUser): Promise { + const client = this.supabaseService.getClient(); + const devices = await listManagedDevices(client, user.sub, user.isStaff); + const nameByDevEui = new Map( + devices.filter((d) => d.canView).map((d) => [d.devEui, d.name]), + ); + if (nameByDevEui.size === 0) return { rules: [] }; + + const { data, error } = await client + .from('cw_device_rule_assignments') + .select('dev_eui, template_id, is_active, cw_rule_templates(id, name, is_active)') + .in('dev_eui', [...nameByDevEui.keys()]) + .eq('is_active', true); + + if (error) { + throw new InternalServerErrorException('Failed to load rule catalog'); + } + + type Row = { + dev_eui: string; + template_id: number; + cw_rule_templates?: + | { id: number; name: string | null; is_active: boolean | null } + | { id: number; name: string | null; is_active: boolean | null }[] + | null; + }; + + const byTemplate = new Map< + number, + { templateId: number; name: string; devices: { devEui: string; name: string | null }[] } + >(); + for (const row of (data ?? []) as Row[]) { + const template = Array.isArray(row.cw_rule_templates) + ? row.cw_rule_templates[0] + : row.cw_rule_templates; + if (!template || template.is_active === false) continue; + let entry = byTemplate.get(row.template_id); + if (!entry) { + entry = { + templateId: row.template_id, + name: template.name ?? `Rule ${row.template_id}`, + devices: [], + }; + byTemplate.set(row.template_id, entry); + } + entry.devices.push({ + devEui: row.dev_eui, + name: nameByDevEui.get(row.dev_eui) ?? null, + }); + } + + return { rules: [...byTemplate.values()] }; + } + + /** + * Lean polling endpoint for field devices (ECHONET bridges, ESP32 units): + * current rule state for the requested devices only, flat and small. + * + * cw_rule_state rows are created lazily on first trigger, so only existing + * rows are returned — a requested device with no row has never triggered and + * callers must treat absence as isTriggered = false. + */ + async getStateForDevices( + user: AuthenticatedUser, + devEuis: string[], + ): Promise { + const ts = new Date().toISOString(); + const requested = uniqueValues(devEuis); + if (requested.length === 0) return { ts, states: [] }; + + const devices = await listManagedDevices( + this.supabaseService.getClient(), + user.sub, + user.isStaff, + ); + const viewable = new Set( + devices.filter((device) => device.canView).map((d) => d.devEui), + ); + // Non-visible devices are dropped silently rather than erroring, matching + // how the rest of this service scopes reads. + const visibleRequested = requested.filter((devEui) => + viewable.has(devEui), + ); + if (visibleRequested.length === 0) return { ts, states: [] }; + + const { data, error } = await this.supabaseService + .getClient() + .from('cw_rule_state') + .select( + 'dev_eui, template_id, is_triggered, last_triggered_at, last_reset_at', + ) + .in('dev_eui', visibleRequested); + + if (error) { + throw new InternalServerErrorException('Failed to load rule state'); + } + + const states = (data ?? []).map((row) => { + const triggeredAt = row.last_triggered_at + ? Date.parse(row.last_triggered_at) + : null; + const resetAt = row.last_reset_at ? Date.parse(row.last_reset_at) : null; + const lastChange = + triggeredAt === null && resetAt === null + ? null + : (triggeredAt ?? 0) >= (resetAt ?? 0) + ? row.last_triggered_at + : row.last_reset_at; + return { + devEui: row.dev_eui, + templateId: row.template_id, + isTriggered: row.is_triggered, + lastChange, + }; + }); + + return { ts, states }; + } + async findTriggeredCount( user: AuthenticatedUser, ): Promise<{ count: number; triggered_count: number; total_count: number }> { diff --git a/supabase/updates/022_push_tokens.sql b/supabase/updates/022_push_tokens.sql new file mode 100644 index 0000000..2a9e619 --- /dev/null +++ b/supabase/updates/022_push_tokens.sql @@ -0,0 +1,28 @@ +-- 022_push_tokens.sql +-- FCM web-push device token registry. +-- +-- One row per browser/device enrollment; a user accumulates one row per +-- enrolled device. The token is the primary key: FCM registration tokens are +-- globally unique, and re-registering the same token (page reload, token +-- refresh) is an idempotent upsert that re-stamps last_seen_at and, when a +-- different account logs in on the same browser, reassigns ownership +-- (last-writer-wins is correct — a token addresses one browser profile). +-- +-- Rows are pruned by the alert service when FCM reports UNREGISTERED for a +-- token, and by the api when the user disables push on a device. Unlike +-- profiles.line_id this IS a token store — acceptable here because FCM +-- registration tokens only let the holder send push messages through our own +-- Firebase project, not act as the user (contrast 009_remove_discord.sql). +-- RLS is enabled with no policies: service-role (admin client) access only. + +create table public.cw_push_tokens ( + token text primary key, + user_id uuid not null references public.profiles (id) on delete cascade, + device_label text, + created_at timestamptz not null default now(), + last_seen_at timestamptz not null default now() +); + +create index cw_push_tokens_user_id_idx on public.cw_push_tokens (user_id); + +alter table public.cw_push_tokens enable row level security; diff --git a/supabase/updates/023_push_action_type.sql b/supabase/updates/023_push_action_type.sql new file mode 100644 index 0000000..60f68fc --- /dev/null +++ b/supabase/updates/023_push_action_type.sql @@ -0,0 +1,21 @@ +-- 023_push_action_type.sql +-- Registers the Push (FCM web push) alert action type. +-- +-- APPLY LAST — only after the LavinMQ-to-Alert build containing +-- PushAlertActionHandler is deployed with a firebase config block. The rules +-- UI action dropdown is data-driven off this table, so inserting this row is +-- what makes the Push option appear (and what activates the default Push +-- action on newly created rules); a Push rule firing against an older alert +-- service falls back to the logging handler (logged, never crashes), but the +-- option should not be user-visible before the handler is live. +-- +-- The name string 'Push' is load-bearing across repos: the alert service's +-- AlertActionRouter and the rules-form branch both match on it exactly. +-- ids are manually assigned in this table (2 = EMail, 3 = LoRaWAN, 4 = LINE). + +insert into public.cw_rule_action_types (id, name) +values (5, 'Push') +on conflict (id) do nothing; + +-- If a sequence is ever attached to cw_rule_action_types.id, bump it past +-- the manual ids: select setval(pg_get_serial_sequence('public.cw_rule_action_types', 'id'), 5, true); From 036b420f9683f6f3e95ee10c3a40206005af59bc Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 25 Aug 2026 13:26:01 +0900 Subject: [PATCH 3/3] linter fix --- src/v1/push/push.controller.ts | 12 ++++++++---- src/v1/rules/rules.controller.spec.ts | 8 ++------ src/v1/rules/rules.controller.ts | 2 +- src/v1/rules/rules.service.ts | 24 ++++++++++++++++++------ 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/v1/push/push.controller.ts b/src/v1/push/push.controller.ts index ac7c531..1699fd0 100644 --- a/src/v1/push/push.controller.ts +++ b/src/v1/push/push.controller.ts @@ -41,7 +41,11 @@ export class PushController { @CurrentUser() user: AuthenticatedUser, @Body() body: RegisterPushTokenDto, ): Promise<{ registered: boolean }> { - await this.pushService.registerToken(user.sub, body.token, body.deviceLabel); + await this.pushService.registerToken( + user.sub, + body.token, + body.deviceLabel, + ); return { registered: true }; } @@ -71,9 +75,9 @@ export class PushController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: "List the current user's registered push tokens" }) - listTokens(@CurrentUser() user: AuthenticatedUser): Promise< - PushTokenSummary[] - > { + listTokens( + @CurrentUser() user: AuthenticatedUser, + ): Promise { return this.pushService.listTokens(user.sub); } diff --git a/src/v1/rules/rules.controller.spec.ts b/src/v1/rules/rules.controller.spec.ts index 47b08e1..5c316ca 100644 --- a/src/v1/rules/rules.controller.spec.ts +++ b/src/v1/rules/rules.controller.spec.ts @@ -32,9 +32,7 @@ describe('RulesController', () => { }); const module: TestingModule = await Test.createTestingModule({ controllers: [RulesController], - providers: [ - { provide: RulesService, useValue: { getStateForDevices } }, - ], + providers: [{ provide: RulesService, useValue: { getStateForDevices } }], }).compile(); const user = { sub: 'user-1', email: 'user@example.com', isStaff: false }; @@ -57,9 +55,7 @@ describe('RulesController', () => { }); const module: TestingModule = await Test.createTestingModule({ controllers: [RulesController], - providers: [ - { provide: RulesService, useValue: { getStateForDevices } }, - ], + providers: [{ provide: RulesService, useValue: { getStateForDevices } }], }).compile(); const user = { sub: 'user-1', email: 'user@example.com', isStaff: false }; diff --git a/src/v1/rules/rules.controller.ts b/src/v1/rules/rules.controller.ts index e5b04df..36b3cb8 100644 --- a/src/v1/rules/rules.controller.ts +++ b/src/v1/rules/rules.controller.ts @@ -139,7 +139,7 @@ export class RulesController { @CurrentUser() user: AuthenticatedUser, @Query('dev_eui') devEui?: string | string[], ) { - const devEuis = (Array.isArray(devEui) ? devEui : (devEui ? [devEui] : [])) + const devEuis = (Array.isArray(devEui) ? devEui : devEui ? [devEui] : []) .flatMap((entry) => entry.split(',')) .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); diff --git a/src/v1/rules/rules.service.ts b/src/v1/rules/rules.service.ts index 48f681d..de720dd 100644 --- a/src/v1/rules/rules.service.ts +++ b/src/v1/rules/rules.service.ts @@ -152,7 +152,9 @@ export class RulesService { const { data, error } = await client .from('cw_device_rule_assignments') - .select('dev_eui, template_id, is_active, cw_rule_templates(id, name, is_active)') + .select( + 'dev_eui, template_id, is_active, cw_rule_templates(id, name, is_active)', + ) .in('dev_eui', [...nameByDevEui.keys()]) .eq('is_active', true); @@ -171,7 +173,11 @@ export class RulesService { const byTemplate = new Map< number, - { templateId: number; name: string; devices: { devEui: string; name: string | null }[] } + { + templateId: number; + name: string; + devices: { devEui: string; name: string | null }[]; + } >(); for (const row of (data ?? []) as Row[]) { const template = Array.isArray(row.cw_rule_templates) @@ -222,9 +228,7 @@ export class RulesService { ); // Non-visible devices are dropped silently rather than erroring, matching // how the rest of this service scopes reads. - const visibleRequested = requested.filter((devEui) => - viewable.has(devEui), - ); + const visibleRequested = requested.filter((devEui) => viewable.has(devEui)); if (visibleRequested.length === 0) return { ts, states: [] }; const { data, error } = await this.supabaseService @@ -239,7 +243,15 @@ export class RulesService { throw new InternalServerErrorException('Failed to load rule state'); } - const states = (data ?? []).map((row) => { + type Row = { + dev_eui: string; + template_id: number; + is_triggered: boolean; + last_triggered_at: string | null; + last_reset_at: string | null; + }; + + const states = ((data ?? []) as Row[]).map((row) => { const triggeredAt = row.last_triggered_at ? Date.parse(row.last_triggered_at) : null;