@7365admin1/core 3.48.1-staging.211 → 3.49.1-staging.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +1707 -0
  2. package/package.json +1 -1
  3. package/.changeset/access-management-site-scope.md +0 -59
  4. package/.changeset/attendance-settings-site-scope.md +0 -5
  5. package/.changeset/attendance-site-and-identity-scope.md +0 -32
  6. package/.changeset/bulletin-board-site-scope.md +0 -19
  7. package/.changeset/bulletin-video-site-scope.md +0 -5
  8. package/.changeset/camera-type-permission-classification.md +0 -46
  9. package/.changeset/customer-site-org-scope.md +0 -48
  10. package/.changeset/estate-structure-write-scope.md +0 -51
  11. package/.changeset/event-management-site-scope.md +0 -16
  12. package/.changeset/facility-booking-site-scope.md +0 -10
  13. package/.changeset/feedback-site-scope.md +0 -51
  14. package/.changeset/form-entry-scope.md +0 -29
  15. package/.changeset/hid-configuration-write-allowlist.md +0 -22
  16. package/.changeset/hrmlabs-attendance-site-scope.md +0 -5
  17. package/.changeset/incident-report-site-scope.md +0 -40
  18. package/.changeset/invoice-subscription-owner-scope.md +0 -23
  19. package/.changeset/manpower-designation-site-scope.md +0 -11
  20. package/.changeset/manpower-monitoring-site-scope.md +0 -37
  21. package/.changeset/manpower-remark-site-scope.md +0 -41
  22. package/.changeset/manpower-sites-empty-sync-guard.md +0 -5
  23. package/.changeset/max-seats-zero-unlimited.md +0 -11
  24. package/.changeset/member-read-and-status-scope.md +0 -40
  25. package/.changeset/member-type-platform-staff-gate.md +0 -71
  26. package/.changeset/member-write-scope.md +0 -60
  27. package/.changeset/nfc-patrol-log-site-scope.md +0 -56
  28. package/.changeset/occurrence-book-site-scope.md +0 -50
  29. package/.changeset/occurrence-entry-site-scope.md +0 -58
  30. package/.changeset/occurrence-subject-site-scope.md +0 -58
  31. package/.changeset/online-form-site-scope.md +0 -15
  32. package/.changeset/org-anon-lookup-projection.md +0 -33
  33. package/.changeset/organization-read-scope.md +0 -44
  34. package/.changeset/patrol-email-site-scope.md +0 -5
  35. package/.changeset/patrol-log-site-scope.md +0 -29
  36. package/.changeset/patrol-question-site-scope.md +0 -22
  37. package/.changeset/patrol-route-site-scope.md +0 -27
  38. package/.changeset/people-read-scope.md +0 -40
  39. package/.changeset/person-lookup-scope.md +0 -44
  40. package/.changeset/person-search-filter-collision.md +0 -26
  41. package/.changeset/person-write-scope.md +0 -38
  42. package/.changeset/provider-billing-org-scope.md +0 -21
  43. package/.changeset/resident-self-signup-field-pinning.md +0 -53
  44. package/.changeset/robot-site-scope.md +0 -5
  45. package/.changeset/role-scope-and-admin-type.md +0 -58
  46. package/.changeset/service-provider-record-scope.md +0 -39
  47. package/.changeset/site-billing-configuration-scope.md +0 -5
  48. package/.changeset/site-billing-item-scope.md +0 -5
  49. package/.changeset/site-entrypass-settings-scope.md +0 -53
  50. package/.changeset/site-facility-site-scope.md +0 -12
  51. package/.changeset/site-org-scope.md +0 -46
  52. package/.changeset/site-soa-scope.md +0 -30
  53. package/.changeset/site-unit-billing-scope.md +0 -25
  54. package/.changeset/staff-camera-status.md +0 -10
  55. package/.changeset/user-email-lookup-no-credentials.md +0 -52
  56. package/.changeset/user-self-scope.md +0 -45
  57. package/.changeset/vehicle-approve-permission-prefix.md +0 -22
  58. package/.changeset/verification-status-transition.md +0 -35
  59. package/.changeset/visitor-guest-scope.md +0 -12
  60. package/.changeset/work-order-site-scope.md +0 -77
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1712 @@
1
1
  # @iservice365/core
2
2
 
3
+ ## 3.49.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8581896: Scope the access-management routes to a site the caller can reach.
8
+
9
+ Every route on `/api/access-management` carried `requireAuth` and nothing more.
10
+ `requireAuth` proves a session exists; it makes no organisation or site
11
+ decision. So any signed-in account on the platform — a resident of a different
12
+ estate, a cleaner, anybody with a login — could read another client's
13
+ access-card inventory, its card holders, the residents behind a unit and the
14
+ block/level/unit structure, and could ISSUE a physical NFC card into that
15
+ estate, generate visitor QR passes for it and stamp QR tags onto its cards.
16
+ These are the credentials that open that estate's doors and lifts.
17
+
18
+ Twenty-eight handlers now decide from the site the request already names,
19
+ before any work is done:
20
+
21
+ - from the body — `addPhysicalCard`, `addNonPhysicalCard`, `acknowlegdeCard`,
22
+ `replaceCard`, `assignAccessCardToUnit`, `vmsgenerateQrCodes`,
23
+ `addVisitorAccessCard`;
24
+ - from the query — `allAccessCardsCounts`, `availableAccessCards`,
25
+ `userTypeAccessCards`, `assignedAccessCards`, `assignedAccessCardsByUnit`,
26
+ `accessandLiftCards`, `doorAndLiftDropdown`, `visitorAccessCards`,
27
+ `bulkPhysicalAccessCard`, `getCardDetails`, `allQrTag`,
28
+ `availableCardContractors`, `getBlockLevelAndUnitList`, `getResidents`,
29
+ `userAccessCards`, `uploadTemplate`, `removeTemplate`, `qrCodeList`;
30
+ - from the URL — `getCardReplacement`, `getAccessManagementSettings`, `addQrTag`.
31
+
32
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`: a membership
33
+ pinned to the site, an org-wide membership at the site's owning organisation, or
34
+ an ACTIVE `customer.sites` engagement. The engagement branch is required, not
35
+ incidental — access cards are administered from the security and
36
+ property-management apps, whose users hold no membership in the estate's own
37
+ organisation.
38
+
39
+ The guard writes its own response instead of throwing. Every handler in this
40
+ controller wraps its body in `try { ... } catch { res.status(400|500) }` rather
41
+ than calling `next(error)`, so a thrown `UnauthorizedError`/`NotFoundError`
42
+ would never reach the error handler and would have surfaced as a 400 with the
43
+ guard's message inside it. It is placed AFTER each handler's Joi validation, so
44
+ a malformed or missing site still answers 400 exactly as before, and only a
45
+ well-formed site the caller cannot reach is refused. That refusal is 404 — what
46
+ a site that does not exist already answers — so site ids cannot be enumerated by
47
+ watching the difference.
48
+
49
+ Six routes on the mount are deliberately NOT changed, because they carry no
50
+ site at all and need per-record resolution:
51
+
52
+ - `POST /sign-qr` and `PATCH /visitor-checkout` — both are called by shipped
53
+ mobile clients that cannot update in lockstep
54
+ (`resident-mobile-app src/actions/entrypass.ts:66`, `isecure365-mobile-app
55
+ src/features/visitors/entry-pass.service.ts:175` and `visitor.service.ts:194`).
56
+ - `PATCH /nfc-status`, `PATCH /delete-card`, `POST /card-replacement`,
57
+ `POST /assign-user` — each identifies a card by id and would need the card's
58
+ own site loaded first.
59
+ - `GET /door-access-levels`, `GET /lift-access-levels`, `GET /access-groups` and
60
+ `POST /settings` take an `acm_url` — an access-control device URL — so they
61
+ are a separate question from site reach and were not touched here.
62
+
63
+ - 01ea9fc: Take the attendance caller's identity from the session, and scope every attendance
64
+ route to its estate.
65
+
66
+ Two separate defects, both on every route of `/api/attendances`.
67
+
68
+ **The caller's identity came from a cookie the caller writes.** Every handler read
69
+ `user` out of the raw `Cookie` header. That cookie is set by the browser itself
70
+ (`layer-common/composables/useLocalAuth.ts`, `setSession`) and by the mobile
71
+ clients from their own stored id; the server never compared it to the session.
72
+ `requireAuth` validates `sid` against Redis and puts the real caller on `req.user`
73
+ — which this controller ignored. A signed-in account could send
74
+ `Cookie: user=<somebody else>` and clock that person in, read their shift history
75
+ and check-in photo, or clock them out; and because check-out `$set`s `user` from
76
+ the request, checking somebody out also re-attributed their shift. The identity
77
+ now comes from `callerId(req)`, as the rest of the codebase resolves it.
78
+
79
+ **The estate was never checked.** All five routes now go through
80
+ `requireSiteReach`, the rule `/api/vehicles`, `/api/documents`,
81
+ `/api/service-providers` and `/api/incident-reports` already use. The four that
82
+ name a site in the URL check it before using it; `GET /id/:id` and
83
+ `PUT /id/:id/check-out` name only a record, so the site is read off the **stored**
84
+ row. The `customer.sites` branch matters here — the contracted agency whose staff
85
+ actually work these shifts holds no membership in the estate's organisation.
86
+
87
+ Every existing client already sends its own id in that cookie, so nothing
88
+ legitimate changes. A bare `Authorization: Bearer` call that sends no cookie at
89
+ all used to fail Joi's `user is required` with a 400 and now succeeds, which is
90
+ the only behaviour that gets looser rather than tighter.
91
+
92
+ - 159a56f: Scope `/api/customer-sites` to the organisations on the record.
93
+
94
+ Every route on this module except the pre-login invite acceptance carried
95
+ `requireAuth` and nothing more, and took the organisation straight out of the
96
+ query string or the body (`customer-site.controller.ts`). Two things followed
97
+ from that, and both are closed here.
98
+
99
+ `POST /api/customer-sites` is not merely an engagement record: it CREATES A SITE
100
+ inside whatever `siteOrg` the body names, and writes that site onto the
101
+ organisation as its default when it has none
102
+ (`customer-site.service.ts:77-111`). Any signed-in account on the platform — a
103
+ resident, a cleaner, a guard — could therefore plant a site inside any client.
104
+
105
+ A `customer.sites` row is also the cross-organisation access grant itself: the
106
+ "engaged agency" branch of `resolveSiteAccess` reads it to decide who may reach
107
+ a site's cameras, HID readers and residents
108
+ (`camera-view.service.ts:325-341`). Being able to write, edit or delete those
109
+ rows by id was being able to grant yourself reach into another client's estate,
110
+ or revoke a contractor's.
111
+
112
+ The caller is now resolved from the session and the decision made before
113
+ anything is read or written:
114
+
115
+ - `POST /` requires `requireOrgAccess` on the body's `org`, and on `siteOrg`
116
+ too when it names a different organisation — because that is where the new
117
+ site lands. The Add / Edit Site form sends the same id for both
118
+ (`web-app-org components/SiteForm.vue:397-402`).
119
+ - `GET /` requires `requireOrgAccess` on the `org` filter the caller sends.
120
+ - `GET /:id`, `PUT /:id` and `DELETE /:id` load the row first and decide from
121
+ the row's own `org` and `siteOrg` — Seven365 staff, or a live member of
122
+ EITHER side of the engagement. Either, not one: the estate's manager reaches
123
+ the row through `siteOrg` and the contracted agency reaches the same row
124
+ through `org`, so scoping to one side would break the other's screen.
125
+ - `GET /service-provider/:id` takes a SITE id, and uses `entitleSite` — the
126
+ same site-reach rule the camera, HID and people modules already use, so an
127
+ agency contracted to a site still lists who else works there.
128
+
129
+ `POST /invite/:id` is unchanged and still reachable with no session: an invited
130
+ organisation accepts before it has an account (`web-app-main pages/sign-in.vue:182`).
131
+
132
+ No new authorization mechanism is introduced. `requireOrgAccess` and
133
+ `entitleSite` are the helpers the member, role, people, camera and HID paths
134
+ already use, and the "either side" test is `resolveInviteActor` asked about two
135
+ organisations in one round trip rather than two.
136
+
137
+ - 6c9b40c: Scope the estate-structure writes to the site they act on.
138
+
139
+ `POST/PATCH/PUT` on `/api/buildings`, `/api/building-levels` and
140
+ `/api/building-units` all carried `requireAuth` and nothing more. The site came
141
+ from the request body on the creates and the record id came from the URL on the
142
+ edits and deletes, and neither was checked against the caller. So any signed-in
143
+ account on the platform could add a block to another client's estate, rename its
144
+ levels in bulk, or delete its units — the records residents, visitor passes,
145
+ billing and access rights are all hung off.
146
+
147
+ Ten routes now decide from the site:
148
+
149
+ - `POST /api/buildings/`, `POST /api/building-levels/`,
150
+ `POST /api/building-units/` — from the site named in the body, which is where
151
+ the new record lands.
152
+ - `PATCH /api/buildings/id/:id`, `PUT /api/buildings/:id`,
153
+ `PUT /api/building-levels/id/:id`, `PUT /api/building-levels/:id`,
154
+ `PATCH /api/building-units/id/:id`, `PUT /api/building-units/:id` — the stored
155
+ record is loaded first and its own site decides. The id in the URL selects the
156
+ record; it never authorises it.
157
+ - `PATCH /api/building-levels/batch` — EVERY id in the batch is checked, not
158
+ just the first, and only the distinct sites are looked up so renaming twelve
159
+ levels of one block stays one check.
160
+
161
+ The rule is `entitleSite`, the site-reach rule the camera, HID and people
162
+ modules already use: a membership pinned to the site, an org-wide membership at
163
+ the site's owning organisation, or an ACTIVE `customer.sites` engagement. The
164
+ engagement branch is required, not incidental — the estate structure is
165
+ maintained from the property-management and service-provider apps, whose users
166
+ may hold no membership in the estate's own organisation.
167
+
168
+ The shared four lines live in a new `src/utils/site-reach.util.ts` so the same
169
+ check is not written out in three controllers. It introduces no second
170
+ mechanism: it resolves the caller with `resolveInviteActor` and defers to
171
+ `entitleSite`, exactly as `person.controller.ts` does.
172
+
173
+ A site the caller cannot reach answers as a site that does not exist, so site
174
+ ids cannot be enumerated by watching the difference. A record with no site at
175
+ all is refused rather than waved through.
176
+
177
+ The READS on these three routers are deliberately unchanged. Six of them share
178
+ a controller function with a pre-login signup alias
179
+ (`/buildings/list/site/resident/:siteId`, `/building-levels/list/site/resident/:siteId`,
180
+ `/building-units/resident/site/:site/block/:block/level/:level` and the three
181
+ `resident` variants), which the resident mobile app calls before an account
182
+ exists. Guarding the handler would break registration on a shipped client, so
183
+ those need the alias split first.
184
+
185
+ - b6c1490: Scope the feedback routes to the estate the feedback was raised at, and take the
186
+ author's identity from the session.
187
+
188
+ A feedback row is a resident's complaint about their own estate: the subject,
189
+ the free-text description, the location inside the block, the attachments and
190
+ the name of the person who raised it. Every route on `/api/feedbacks` carried
191
+ `requireAuth` and nothing more, and the controller held no authorization
192
+ identifier of any kind, so being signed in anywhere on the platform was enough
193
+ to read another client's complaints, rewrite them, re-route them to a different
194
+ service line, close them, or delete them.
195
+
196
+ `DELETE /:id` is the sharp one, twice over. It is the **only** route on this
197
+ mount with a live caller — `layer-common`'s `useFeedback().deleteFeedback`,
198
+ wired to the delete button in `FeedbackDetail.vue` and `FeedbackMain.vue`, which
199
+ every web app draws — and it does not write to the v1 table at all. It writes to
200
+ `feedbacks2`, the authoritative table every client reads. So the one reachable
201
+ route on the legacy mount was an unguarded delete against live data. It is now
202
+ checked against the `feedbacks2` row it actually touches, not the v1 row that
203
+ happens to share the id.
204
+
205
+ All eight routes go through `requireSiteReach`, the rule
206
+ `/api/manpower-monitoring`, `/api/attendances`, `/api/remarks`,
207
+ `/api/vehicles`, `/api/documents` and `/api/incident-reports` already use:
208
+
209
+ - `GET /site/:site/status/:status` names the site in the URL, so it is checked
210
+ before the read.
211
+ - The six record routes name only a record, so the site is read off the
212
+ **stored** row and a `site` in the caller's own body cannot stand in for it.
213
+ Two readers are needed, because the two tables are not the same shape: v1
214
+ `feedbacks` rows keep the site at `metadata.site`, `feedbacks2` rows keep it
215
+ at the top level.
216
+ - `POST /` took the author from a `user` cookie the caller writes and never
217
+ compared it to the session, so a complaint could be filed in somebody else's
218
+ name. It comes from `callerId(req)` now, which also removes a latent crash:
219
+ `cookies?.["user"].toString()` short-circuits only on `cookies` being nullish
220
+ and threw on any request carrying cookies but no `user` cookie.
221
+
222
+ `requireSiteReach` matters here because the contracted service provider that
223
+ answers these complaints holds no membership in the estate's organisation and
224
+ reaches it through `customer.sites`.
225
+
226
+ Not fixed here, and reported separately: `POST /api/feedbacks` cannot succeed
227
+ and could not before this change either. `feedback.service.ts` replaces
228
+ `createdBy` with an `ObjectId` and `MFeedback` then re-validates the document
229
+ against a schema where `createdBy` is `Joi.string().hex()`, so the create always
230
+ answers 400. No client calls it — every client creates through
231
+ `/api/feedbacks2`.
232
+
233
+ - 9a0df17: Scope the incident report routes to the estate the report was written at.
234
+
235
+ Every route on `/api/incident-reports` carried `requireAuth` and nothing more, so
236
+ being signed in anywhere on the platform was enough to page another estate's
237
+ incident log — complainant name, contact, NRIC, unit, the guard's narrative and
238
+ the photographs — and to open, rewrite, approve, reject or delete any single
239
+ report, or file one at any estate.
240
+
241
+ All eight now go through `requireSiteReach`, the rule `/api/vehicles`,
242
+ `/api/documents` and `/api/service-providers` already use:
243
+
244
+ - the two lists (`GET /`, `GET /incident-report`) and the two creates
245
+ (`POST /`, `POST /summary`) name a site in the request; that site is checked
246
+ before it is used.
247
+ - the four id routes (`GET /id/:id`, `PUT /id/:id`, `PUT /review/:id`,
248
+ `PUT /:id`) name only a record, so the site is read off the **stored** record.
249
+ `PUT /id/:id` carries a `site` of its own in the body, and authorising on that
250
+ would have let a caller quote their own estate beside another estate's report
251
+ id — the shape already found on `/api/vehicles`.
252
+
253
+ `requireSiteReach` matters more here than anywhere else: a contracted security
254
+ agency holds no membership in the estate's organisation and reaches its own site
255
+ through `customer.sites` — and the agency's guards are who **write** these
256
+ reports. A flat organisation check would have locked them out of their own work.
257
+
258
+ A report the caller cannot reach answers exactly as one that does not exist
259
+ (404), so ids cannot be enumerated from the difference. Guards sit after each
260
+ handler's Joi validation, so a malformed id still answers 400 exactly as today.
261
+
262
+ Note on the count: the route inventory listed six handlers on this mount as
263
+ unguarded. There are eight, and `POST /` and `GET /` were open too — the
264
+ controller contains no authorization identifier of any kind at the `staging` tip.
265
+ All eight are closed here.
266
+
267
+ Nothing changes for a permitted caller: same response shape, same fields, same
268
+ status.
269
+
270
+ - b62c6a4: Scope the manpower monitoring settings routes to the site they belong to.
271
+
272
+ Every route on `/api/manpower-monitoring` carried `requireAuth` and nothing more,
273
+ so being signed in anywhere on the platform was enough to read another client's
274
+ shift pattern and alert email recipients, to re-time their late-check-in alert, or
275
+ to switch their monitoring off entirely — which silences the alert that tells a
276
+ client nobody turned up for a shift.
277
+
278
+ Five of the six now go through `requireSiteReach`, the rule `/api/vehicles`,
279
+ `/api/documents`, `/api/service-providers` and `/api/incident-reports` already
280
+ use:
281
+
282
+ - `POST /`, `GET /sites/:id/:serviceProviderId` and `POST /multiple` name a site
283
+ in the request; that site is checked before it is used. `POST /multiple`
284
+ upserts **by** each item's own `siteId`, so every item is checked before any of
285
+ them is written.
286
+ - `PUT /:id` names only a record, so the site is read off the **stored** row —
287
+ a `siteId` in the caller's own body cannot stand in for it.
288
+ - `GET /search` names no site at all and returned every client's row on one page.
289
+ It is narrowed after the read, the way the people and vehicle lookups are.
290
+
291
+ `requireSiteReach` matters here because the contracted agency that actually runs
292
+ these shifts holds no membership in the estate's organisation and reaches its own
293
+ site through `customer.sites`.
294
+
295
+ `GET /sites/active` is deliberately unchanged. It reads nothing from our database
296
+ — it authenticates to the external HRM Labs directory and returns that system's
297
+ own site tree — and the only web caller sends a hard-coded service provider id
298
+ rather than the signed-in one, so scoping it on that id would close the screen for
299
+ its real users. It needs a product decision, not a guard chosen here.
300
+
301
+ A settings row the caller cannot reach answers exactly as one that does not exist,
302
+ so site ids cannot be enumerated by watching the difference.
303
+
304
+ - 638320e: Scope the manpower remarks routes to the site the remarks belong to.
305
+
306
+ A remarks row is the shift-attendance exception record for one site on one day:
307
+ which named guards were late or absent, and whether the estate has acknowledged
308
+ it. Every route on `/api/remarks` carried `requireAuth` and nothing more — the
309
+ controller held no authorization identifier of any kind — so being signed in
310
+ anywhere on the platform was enough to read another client's daily manning
311
+ failures, to write an acknowledgement into their record in a name of the
312
+ caller's choosing, or to switch a row to `inactive` so the estate's own console
313
+ stops listing it.
314
+
315
+ All five routes now go through `requireSiteReach`, the rule
316
+ `/api/manpower-monitoring`, `/api/attendances`, `/api/vehicles`,
317
+ `/api/documents` and `/api/incident-reports` already use:
318
+
319
+ - `GET /:id/:serviceProviderId` names the site in the URL, so it is checked
320
+ before the read.
321
+ - `PUT /:id` and `PATCH /:id` name only a record, so the site is read off the
322
+ **stored** row — a `siteId` in the caller's own body cannot stand in for it.
323
+ - `GET /` names no site at all. Its only filter is a `serviceProviderId` the
324
+ caller supplies, which is not a claim about the caller, and one agency works
325
+ for several clients — so the page came back holding every one of those
326
+ clients' rows. It is narrowed after the read, the way
327
+ `/api/manpower-monitoring`'s own `GET /search` is. The count in the envelope
328
+ stays the unfiltered one, as it does there; both callers read it only for the
329
+ pager.
330
+ - `POST /` writes a row for whatever `siteId` the body carries. It has no caller
331
+ in any web app or mobile app — the rows are written internally by
332
+ `manpower-monitoring.service.ts` when a site's monitoring settings are created
333
+ — and it is guarded all the same rather than left open.
334
+
335
+ `requireSiteReach` matters here because the contracted agency that actually runs
336
+ these shifts holds no membership in the estate's organisation and reaches its
337
+ own site through `customer.sites`.
338
+
339
+ A row the caller cannot reach answers exactly as one that does not exist, so
340
+ site ids cannot be enumerated by watching the difference.
341
+
342
+ - c01b07e: Scope the member list and the member suspend/reactivate write to their own organisation.
343
+
344
+ `GET /api/members` took its `org` and `siteId` straight out of the query string,
345
+ and `PUT /api/members/:id/:status` took the member id straight out of the URL.
346
+ Both carried `requireAuth` and nothing more. So any signed-in account on the
347
+ platform could list another client's staff, and suspend or reactivate any member
348
+ of any client.
349
+
350
+ `GET /api/members` also had a hole in its own scope requirement. Line 170 refused
351
+ a query that named neither an organisation nor a user — **unless**
352
+ `type === "admin"`, which then listed Seven365's own staff to anybody signed in.
353
+
354
+ Both now decide from the session:
355
+
356
+ - `type=admin` requires Seven365 staff. That query belongs to the staff console
357
+ (`web-app-org pages/super-admin`), so it is gated rather than removed.
358
+ - a caller-supplied `org` is checked with `requireOrgAccess` before it is used —
359
+ Seven365 staff, or a live member of that organisation.
360
+ - `user` on its own stays open for the caller's OWN id, because "which
361
+ organisations am I a member of" is every service app's landing page
362
+ (`web-app-security pages/index.vue:118` and its siblings). Asking it about
363
+ somebody else now requires staff.
364
+ - `PUT /api/members/:id/:status` loads the stored membership and authorises on
365
+ the organisation recorded on it — never one from the request. A membership with
366
+ no organisation is a platform staff row, and only Seven365 staff may touch it;
367
+ a missing org is refused, not waved through.
368
+
369
+ Service providers are not special-cased and do not need to be: a provider holds a
370
+ membership in the organisation whose site it works on — that is how
371
+ `GET /api/members/user/:id/app/:type?org=` finds its own row at sign-in — so the
372
+ guard app's member list (`isecure365-mobile-app
373
+ src/features/patrol/use-patrol.ts:180`, which sends `org: site.org` with a
374
+ `siteId`) passes on the ordinary rule.
375
+
376
+ Nothing changes for a permitted caller: same response shape, same fields, same
377
+ status.
378
+
379
+ - e76658f: Only Seven365 staff can create a Seven365 staff membership.
380
+
381
+ `members.type` decides who is platform staff. `isSuperAdmin` answers true for a
382
+ live `members` row of type `"admin"` whose role is also of type `"admin"`, and
383
+ that field was validated as `Joi.string().required()` — a free-form string with
384
+ no allow-list. Every request path that writes a membership takes the value
385
+ straight from the caller:
386
+
387
+ - `POST /api/members/direct` writes the body's `app` verbatim as `type`
388
+ (`member.service.ts:137`);
389
+ - `POST /api/auth/invite` stores the body's `app` on the invitation, and
390
+ accepting it writes that value as `type` (`member.service.ts:73`);
391
+ - `PUT /api/members/id/:id/role/:role/type/:type/org/:org` re-pointed a
392
+ membership at any role, including the seeded platform one, checking only that
393
+ an owner role existed and that the target was not the last owner — never who
394
+ was asking.
395
+
396
+ All three are `requireAuth`-only. So any signed-in account — a resident, a
397
+ cleaner, a guard — could post `{ app: "admin" }`, or invite itself as `admin`
398
+ and accept its own invitation, and be Seven365 platform staff on the next
399
+ request. That defeats every gate built on `isSuperAdmin`: the staff console,
400
+ service-provider approvals, the notification broadcast and client suspension.
401
+ Executed end to end against a throwaway in-memory database, the chain succeeded
402
+ at every step.
403
+
404
+ Three changes, all fail-closed:
405
+
406
+ - `members.type` now validates against `MEMBER_TYPES`, enumerated from the
407
+ values this package actually writes — `admin` (`user.service.ts`
408
+ `createDefaultUser` and the console's own invitations), `organization` (the
409
+ default in `member.service.ts`, `user.service.ts`, `user-v2.service.ts` and
410
+ `subscription.service.ts`), `resident` (`person.service.ts`), and the eight
411
+ `AppServiceType` service memberships, the same list `organization.model.ts`
412
+ already accepts as an organisation's `nature`.
413
+ - `createMemberDirect` refuses `app: "admin"`, and refuses a role of type
414
+ `"admin"`, unless the caller is already platform staff. The caller is resolved
415
+ from the session by the controller and passed in; it is never read from a body
416
+ or a URL.
417
+ - `createUserInvite` refuses `app: "admin"` unless the caller is already
418
+ platform staff, so the invitation road to the same place is shut too.
419
+
420
+ `updateRoleById` refuses to move a membership onto a role of type `"admin"` for
421
+ the same reason — that was step 6 of the chain, the one that turned
422
+ `isPlatformOwner` true by pointing a membership at the seeded owner role.
423
+
424
+ The seeded role marker `default: true` was re-checked rather than assumed and is
425
+ still not forgeable: Joi rejects unknown keys on all three role schemas.
426
+
427
+ Every legitimate caller across the sibling repos was checked and still works.
428
+ `web-app-main pages/sign-in.vue` and `pages/verify/email/index.vue`, and
429
+ `web-app-org pages/onboarding/getting-started/index.vue`, all pass
430
+ `app: "organization"` or a service type — none reaches a gate. The one caller
431
+ that does send `"admin"` is the staff console itself
432
+ (`web-app-org pages/super-admin/invitations/index.vue:403`), operated by an
433
+ account that already is staff.
434
+
435
+ One transitional case is kept deliberately. `web-app-org
436
+ pages/org/[organization]/invitations/invite.vue:122` and `web-app-hygiene
437
+ pages/[org]/[site]/invitations/invite.vue:135` send the ORGANISATION ID as the
438
+ invitation's `app`, so accepting one of those invitations writes an org id into
439
+ `members.type`. That is a defect in those two screens, but it is live: the
440
+ validator therefore also accepts a 24-character hex string, with the two file
441
+ references named at the line, to be removed once both screens send a real
442
+ membership type. It cannot be used to escalate — `"admin"` is not 24 hex
443
+ characters, so the allow-list still decides that.
444
+
445
+ No response shape, status code or field changed for a permitted caller.
446
+
447
+ - 43dc4bc: Scope the three remaining `/api/members` writes to the membership they name.
448
+
449
+ `PUT /api/members/id/:id/role/:role/type/:type/org/:org`,
450
+ `PUT /api/members/site` and `PUT /api/members/:id/onboarding/complete` all
451
+ carried `requireAuth` and nothing more, and each took the membership id straight
452
+ out of the request.
453
+
454
+ **The role write is the sharp one.** A role IS the permission set, so
455
+ re-pointing a membership at another role is an access grant. The service already
456
+ refused to hand a _Seven365 staff_ role to a non-staff caller, but a **tenant**
457
+ role was still free: quoting another client's membership id re-pointed it at any
458
+ of that client's roles, their **owner** role included — and equally could demote
459
+ that client's own admin. The `org` in the URL was also what the service used to
460
+ look up owner roles for its "last owner" precondition, so a mismatched one ran
461
+ that safety check against the wrong client's roles entirely.
462
+
463
+ `PUT /api/members/site` took the membership id **and** the target site from the
464
+ body, so nothing said whose membership, or whose site, either one was — any
465
+ signed-in account could move any client's member onto any estate.
466
+
467
+ `PUT /api/members/:id/onboarding/complete` took the id from the URL and nothing
468
+ else, so any signed-in account could mark any client's membership onboarded and
469
+ skip that client's onboarding gate.
470
+
471
+ All three now decide from the **stored** membership, through the
472
+ `requireMemberOrg` helper this controller already uses for
473
+ `PUT /api/members/:id/:status`:
474
+
475
+ - the organisation recorded on the membership is authorised with
476
+ `requireOrgAccess` — Seven365 staff, or a live member of that organisation.
477
+ - a membership with **no** organisation is a platform staff row; only Seven365
478
+ staff may touch it. A missing org is refused, not waved through.
479
+ - the role write additionally refuses when the `org` in the URL disagrees with
480
+ the one on the membership, so the service's owner-role lookup can no longer be
481
+ pointed at another client.
482
+ - `PUT /api/members/site` puts the incoming site through `requireSiteReach`, the
483
+ same rule `/api/vehicles` and `/api/documents` use, so a member cannot be moved
484
+ onto an estate the caller cannot reach. An unreachable site answers as one that
485
+ does not exist (404), so site ids cannot be enumerated from the difference.
486
+
487
+ Guards sit **after** each handler's Joi validation, so a malformed id still
488
+ answers 400 exactly as it does today.
489
+
490
+ Scope note — deliberately NOT changed here: the four read-by-user routes
491
+ (`GET /api/members/user/:id`, `/users/:id`, `/user/:id/app/:type`,
492
+ `/user/:id/app/:type/org`). `layer-common ServiceProviderMain.vue:922,1320` and
493
+ `InvitationClientForm.vue:324` legitimately read the memberships of a user who
494
+ belongs to a **service provider's** organisation, not to the caller's, and no
495
+ existing helper expresses "an organisation my organisation has engaged". That
496
+ needs a product decision rather than a guess. Every other caller passes its own
497
+ user id. `POST /api/members/direct` and `POST /api/members/verification/:id` are
498
+ also unchanged — the latter is driven entirely by a single-use, expiring
499
+ invitation record and grants the caller nothing.
500
+
501
+ Nothing changes for a permitted caller: same response shape, same fields, same
502
+ status.
503
+
504
+ - 5a46062: Scope the NFC patrol log routes to the estate the round was walked at, and take
505
+ the author from the session.
506
+
507
+ An NFC patrol log is the proof that a round was actually walked: the route, the
508
+ day, each checkpoint the guard tapped a tag against, the ones that were skipped
509
+ and why, and the signature that closes the round off. It is the document a
510
+ client is shown when they ask whether their estate was patrolled.
511
+
512
+ Every route on `/api/nfc-patrol-log` carried `requireAuth` and nothing more, and
513
+ `nfc-patrol-log.controller.ts` held no authorization identifier of any kind.
514
+ `requireAuth` proves a session exists; it makes no organisation or site
515
+ decision. So being signed in anywhere on the platform was enough to read another
516
+ client's patrol history, **mark a checkpoint on another estate's round as
517
+ completed**, or **sign that round off as done** — falsifying the patrol proof,
518
+ not merely reading it.
519
+
520
+ All five handlers now decide before any work is done. The route count was
521
+ verified at the registration site — `nfc-patrol-log.route.ts` mounts **five**
522
+ routes, not the four the earlier inventory recorded.
523
+
524
+ - `POST /` and `GET /` already REQUIRE a site (`schemaCreateNfcPatrolLog` and
525
+ the query schema both have `site` required), so the guard checks a site the
526
+ request already names and no caller's contract changes. The repository does
527
+ genuinely apply that `site` — it is the first term of the aggregation match,
528
+ not a parameter it accepts and ignores.
529
+ - `PATCH /:id/checkpoint`, `GET /:id` and `PATCH /:id/sign` name only a record,
530
+ so the site is read off the STORED log through `requireNfcPatrolLogReach`.
531
+ `getById` on this repository is NOT cached, so no separate raw reader was
532
+ needed here, unlike the occurrence mounts.
533
+ - `POST /`'s `createdBy` came from the request body and was never compared to
534
+ the session. It comes from `callerId(req)` now. The field is still accepted,
535
+ so no caller starts to 400; it is simply ignored.
536
+
537
+ None of the three record routes can MOVE a log: `updateCheckpoint` sets only the
538
+ checkpoint fields and `sign` sets only the signature fields, so there is no
539
+ destination to check.
540
+
541
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
542
+ `customer.sites` branch is load-bearing here more than on any other mount: the
543
+ people who walk these rounds are the contracted security agency's guards and
544
+ hold no membership in the estate's own organisation.
545
+
546
+ `GET /:id` previously answered **200 with `null`** for an id matching nothing,
547
+ because the handler passed the repository's `null` straight to `res.json`. It
548
+ now refuses — a log that does not exist has no site, and `requireSiteReach`
549
+ refuses a missing site.
550
+
551
+ Sibling mounts on the same family — `/nfc-patrol-tag`, `/nfc-patrol-settings`,
552
+ `/nfc-patrol-route` and `/patrol-routes` — are NOT touched here. The first three
553
+ carry `/public` routes behind `requireSessionOrLegacyToken`, which is inert
554
+ while `ENFORCE_NFC_PUBLIC_AUTH` is off, so guarding their shared handlers would
555
+ refuse a live legacy screen. That needs the SEC-028 decision first.
556
+
557
+ - ef9a540: Scope the daily occurrence book routes to the estate the book belongs to.
558
+
559
+ The daily occurrence book is one estate's security log for one day — the record
560
+ the guards write every shift, and the record an estate is asked to produce when
561
+ something goes wrong. Every route on `/api/occurrence-books` carried
562
+ `requireAuth` and nothing more, and `occurrence-book.controller.ts` held no
563
+ authorization identifier of any kind. `requireAuth` proves a session exists; it
564
+ makes no organisation or site decision. So being signed in anywhere on the
565
+ platform was enough to read another client's log, re-date it, re-open or close
566
+ it, or delete the day outright.
567
+
568
+ All five handlers now decide before any work is done. The route count was
569
+ verified at the registration site — `occurrence-book.route.ts` mounts **five**
570
+ routes, not the three the earlier inventory recorded.
571
+
572
+ - `POST /` and `GET /` already REQUIRE a site (in the body and the query
573
+ respectively), so the guard checks a site the request already names and no
574
+ caller's contract changes.
575
+ - `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
576
+ site is read off the STORED row through the new `requireOccurrenceBookReach`.
577
+ A `site` in the caller's own body or query cannot stand in for it.
578
+
579
+ `requireOccurrenceBookReach` is built on a new raw reader,
580
+ `getRawOccurrenceBookById`. The existing `getOccurrenceBookById` is CACHED and
581
+ throws `NotFoundError`, so a reach check built on it would have decided who may
582
+ touch a book from a cache entry, and would have answered 404 with the wrong
583
+ message before the guard ever ran.
584
+
585
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`, already used
586
+ by `/api/feedbacks`, `/api/work-orders`, `/api/manpower-monitoring`,
587
+ `/api/attendances`, `/api/remarks`, `/api/vehicles`, `/api/documents` and
588
+ `/api/incident-reports`. The `customer.sites` branch is load-bearing here: the
589
+ guards who WRITE this book are the contracted security agency's staff and hold
590
+ no membership in the estate's own organisation, so a flat organisation check
591
+ would lock the security app out of the log it exists to keep.
592
+
593
+ Each guard sits AFTER the handler's own Joi validation, so a malformed id still
594
+ answers 400 exactly as before. A row that does not exist has no site, and
595
+ `requireSiteReach` refuses a missing site, so an id matching nothing is refused
596
+ rather than falling through.
597
+
598
+ `PUT /id/:id` cannot MOVE a book — `schemaUpdateOccurrenceBook` does not accept
599
+ `site` at all — so there is no destination to check.
600
+
601
+ `/api/occurrence-entries` and `/api/occurrence-subjects` are separate mounts and
602
+ are not touched here; each gets its own change.
603
+
604
+ - cb7bcd3: Scope the occurrence entry routes to the estate the entry belongs to.
605
+
606
+ An occurrence entry is a single line the guards write into an estate's daily
607
+ security log — the time, the subject, the free-text account of what happened, the
608
+ signature of the guard who wrote it, and any incident report attached to it.
609
+ Every route on `/api/occurrence-entries` carried `requireAuth` and nothing more,
610
+ and `occurrence-entry.controller.ts` held no authorization identifier of any
611
+ kind. `requireAuth` proves a session exists; it makes no organisation or site
612
+ decision. So being signed in anywhere on the platform was enough to read another
613
+ client's log line by line, rewrite what an entry says, or delete an entry from
614
+ the record.
615
+
616
+ All six handlers now decide before any work is done. The route count was
617
+ verified at the registration site — `occurrence-entry.route.ts` mounts **six**
618
+ routes, not the four the earlier inventory recorded.
619
+
620
+ - `POST /` and `GET /` already REQUIRE a site (in the body and the query
621
+ respectively), so the guard checks a site the request already names and no
622
+ caller's contract changes.
623
+ - `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
624
+ site is read off the STORED row through the new `requireOccurrenceEntryReach`,
625
+ built on a new raw reader `getRawOccurrenceEntryById` (the existing
626
+ `getOccurrenceEntryById` is CACHED).
627
+ - `GET /count/bookId/:id` is the one shape not seen on the earlier mounts: the
628
+ id identifies a **book**, not an entry. Its scope therefore comes from the
629
+ BOOK's row, through `requireOccurrenceBookSiteReach`.
630
+
631
+ That last one matters. Deriving reach from "some entry carrying this book id"
632
+ would have refused the FIRST entry of every new day, because a freshly opened
633
+ book has no entries to read — the security app calls this route to work out the
634
+ next serial number before writing that first line. There is a regression case
635
+ for exactly that.
636
+
637
+ `requireOccurrenceBookSiteReach` deliberately uses the book repository's
638
+ ordinary (cached) reader. A book's `site` is immutable — no route on
639
+ `/api/occurrence-books` accepts `site` on update — so a cached row cannot carry
640
+ a site that differs from the stored one, and the usual objection to deciding
641
+ reach from a cache does not apply here.
642
+
643
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
644
+ `customer.sites` branch is load-bearing: the guards who WRITE these entries are
645
+ the contracted security agency's staff and hold no membership in the estate's
646
+ own organisation.
647
+
648
+ Each guard sits AFTER the handler's own Joi validation, so a malformed id still
649
+ answers 400 exactly as before. A row that does not exist has no site, and
650
+ `requireSiteReach` refuses a missing site, so an id matching nothing is refused
651
+ rather than falling through.
652
+
653
+ `PUT /id/:id` cannot MOVE an entry — `schemaUpdateOccurrenceEntry` does not
654
+ accept `site` at all — so there is no destination to check.
655
+
656
+ `/api/occurrence-books` and `/api/occurrence-subjects` are separate mounts and
657
+ are not touched here; each gets its own change.
658
+
659
+ - 60049e1: Scope the occurrence subject routes to the estate the subject belongs to, and
660
+ take the author from the session.
661
+
662
+ An occurrence subject is one of the named headings the guards pick from when
663
+ they write a line into an estate's daily security log — "Lift breakdown",
664
+ "Trespasser", "Fire alarm". The list belongs to one site. Every route on
665
+ `/api/occurrence-subjects` carried `requireAuth` and nothing more, and
666
+ `occurrence-subject.controller.ts` held no authorization identifier of any kind.
667
+ `requireAuth` proves a session exists; it makes no organisation or site
668
+ decision. So being signed in anywhere on the platform was enough to read another
669
+ client's subject list, add a heading to it, rename one, or delete one out from
670
+ under the guards who are picking from it.
671
+
672
+ Two defects, not one:
673
+
674
+ 1. **No scope.** All five handlers now decide before any work is done. The route
675
+ count was verified at the registration site — `occurrence-subject.route.ts`
676
+ mounts **five** routes, not the three the earlier inventory recorded.
677
+ 2. **Identity from the caller's body.** `POST /` took `addedBy` straight out of
678
+ the request body and never compared it to the session, so a heading could be
679
+ filed under somebody else's name. It comes from `callerId(req)` now. The
680
+ shipped mobile client's own comment records the old behaviour — "`addedBy` is
681
+ sent because the model stores it; the server does not take it from the
682
+ session". Same defect already fixed on `/api/attendances`,
683
+ `/api/site-entrypass-settings` and `/api/feedbacks`.
684
+
685
+ - `POST /` and `GET /` already REQUIRE a site (`schemaOccurrenceSubject` and the
686
+ query schema both have `site` required), so the guard checks a site the
687
+ request already names and no caller's contract changes.
688
+ - `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
689
+ site is read off the STORED row through the new
690
+ `requireOccurrenceSubjectReach`, built on a new raw reader
691
+ `getRawOccurrenceSubjectById` (the existing `getOccurrenceSubjectById` is
692
+ CACHED, so a guard built on it would decide reach from a cache entry).
693
+
694
+ `GET /`'s `site` is genuinely applied by the repository — it is part of the
695
+ `baseQuery` and of the cache key — so the list needed a reach check on the site
696
+ it names, not a narrowing pass after the read.
697
+
698
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
699
+ `customer.sites` branch is load-bearing: the guards who maintain this list are
700
+ the contracted security agency's staff and hold no membership in the estate's
701
+ own organisation.
702
+
703
+ Each guard sits AFTER the handler's own Joi validation, so a malformed id still
704
+ answers 400 exactly as before. A row that does not exist has no site, and
705
+ `requireSiteReach` refuses a missing site, so an id matching nothing is refused
706
+ rather than falling through.
707
+
708
+ `PUT /id/:id` cannot MOVE a subject — `schemaUpdateOccurrenceSubject` accepts
709
+ only `_id` and `subject` — so there is no destination to check.
710
+
711
+ This closes the occurrence-book family: `/occurrence-books`,
712
+ `/occurrence-entries` and `/occurrence-subjects`.
713
+
714
+ - 1e93185: Scope the organisation lookups so they stop mapping one client to another.
715
+
716
+ Four reads on `/api/organizations` carried `requireAuth` and nothing more, and
717
+ every one took its key straight from the URL:
718
+
719
+ - `GET /api/organizations/user/:user` — which organisations ANY person belongs to
720
+ - `GET /api/organizations/orgs/:email` — the same, keyed on ANY email address
721
+ - `GET /api/organizations/name/:name` — any organisation, by name
722
+ - `GET /api/organizations/email/:email` — any organisation, by email address
723
+
724
+ Together those are a cross-client directory and an account-enumeration oracle: a
725
+ resident of one estate could map which clients an address or a user id belongs
726
+ to across the whole platform.
727
+
728
+ Each is now decided from the session:
729
+
730
+ - `user/:user` — the caller's own id, or Seven365 staff. Every live caller asks
731
+ about itself: the seven service-provider app layouts, the org app, and the
732
+ resident mobile app all pass the signed-in user's own id.
733
+ - `orgs/:email` — the caller's own address, compared case-insensitively after
734
+ being resolved from the session, or Seven365 staff. All three live callers
735
+ pass the signed-in user's own address.
736
+ - `name/:name` — Seven365 staff. Its only caller is the staff console's client
737
+ list.
738
+ - `email/:email` — Seven365 staff. No caller exists in any repository in the
739
+ organisation.
740
+
741
+ A key that does not exist is refused exactly as one that does, so the refusal
742
+ itself is not an oracle.
743
+
744
+ `GET /api/organizations/id/:id` is deliberately NOT guarded here: the same
745
+ controller function is mounted a second time, unauthenticated, at
746
+ `/api/organizations/org/id/:id`, which the sign-up-invite screens call before an
747
+ account exists. Guarding the handler would guard both mounts and break
748
+ registration. That one needs the alias split first.
749
+
750
+ `GET /api/organizations/` (the full client list) is also unchanged and is
751
+ recorded as a decision for the leads: it is reached from a live signup path that
752
+ must move onto the organisation `POST /api/organizations/onboarding` already
753
+ returns before the list can be staff-gated.
754
+
755
+ - 07ff8c9: Scope `GET /api/people` to a site the caller can actually reach.
756
+
757
+ The endpoint returns resident and visitor records — name, NRIC, contact number,
758
+ unit, plate numbers — and filters on NRIC, contact and plate number. It carried
759
+ `requireAuth` and nothing more, and took its `org` and `site` from the query
760
+ string (`person.controller.ts:79-80, 93`). So any signed-in account on the
761
+ platform could page through another estate's residents, or look a person up by
762
+ NRIC across every client at once.
763
+
764
+ The caller is now resolved from the session and the scope decided before
765
+ anything is read:
766
+
767
+ - a named `site` is checked with `entitleSite` — the same site-reach rule the
768
+ camera and HID modules already use: a membership at the site, an org-wide
769
+ membership at the site's owning organisation, or an ACTIVE `customer.sites`
770
+ engagement. Reusing it matters rather than being tidy: an agency contracted to
771
+ an estate holds no membership in that estate's organisation, so an
772
+ org-membership check would have locked every guard out of the site they work
773
+ at. A site the caller cannot reach answers exactly as a site that does not
774
+ exist, so site ids cannot be enumerated.
775
+ - a named `org` is checked against the caller's own memberships.
776
+ - naming neither — the NRIC, contact and plate lookups — is narrowed to the
777
+ caller's own organisations instead of searching the whole platform.
778
+ - Seven365 staff keep cross-tenant reach, unchanged.
779
+
780
+ Every live caller passes a site: `layer-common` `VisitorForm.vue`,
781
+ `MemberInformation.vue` and `VehicleForm.vue` all pass the site the screen is
782
+ open on, and the People list passes org and site from its route. The two
783
+ composable functions that pass neither — `usePeople().findPersonByContact` and
784
+ `findUsersByPlateNumber` — have no caller in any repository.
785
+
786
+ `person.repo.getAll` gains an optional `orgs` filter for the last case. It
787
+ applies only when no explicit `org` was given, so it cannot fight one, and its
788
+ absence means "no restriction", which is what staff get.
789
+
790
+ No response shape, status code or field changed for a permitted caller.
791
+
792
+ - edae7cb: Scope the single-value people lookups to a site the caller can reach.
793
+
794
+ Seven endpoints on `/api/people` carried `requireAuth` and nothing else and were
795
+ not scoped at all:
796
+
797
+ | Route | What it answered |
798
+ | ------------------------------- | ------------------------------------------------- |
799
+ | `GET /nric/:nric` | a `findOne` on NRIC across the **whole platform** |
800
+ | `GET /contact/:contact` | the same, on contact number |
801
+ | `GET /plateNumber/:plateNumber` | every person with that plate, anywhere |
802
+ | `GET /unit/:unit` | every person at that unit id |
803
+ | `GET /user/:userId` | the person record behind any user id |
804
+ | `GET /company` | company names aggregated across every client |
805
+ | `GET /all-nric` | a paged NRIC list at a caller-named site |
806
+
807
+ So any signed-in account could type an NRIC or a car plate and read the matching
808
+ resident of any estate — name, unit, contact number, plates. #1907 scoped the
809
+ paged list (`GET /api/people`); these are the lookups beside it.
810
+
811
+ None of them takes a site — `layer-common usePeople.findPersonByNRIC`,
812
+ `searchCompanyList` and `getPeopleByUnit` pass only the value being looked up —
813
+ so the scope is decided **after** the read, on the record, with the same
814
+ site-reach rule the camera, HID and people-list already use. A record the caller
815
+ cannot reach is left out, which for these endpoints is exactly what "no such
816
+ person" already looked like: `null`, or an empty list. **No client sees a new
817
+ response shape or a new status code.** `entitleSite` is asked once per distinct
818
+ site, not once per record.
819
+
820
+ `/all-nric` requires a site in the query, so that one is checked before the read
821
+ and answers `404` for a site the caller cannot reach — the same answer a site
822
+ that does not exist gives, so ids cannot be probed.
823
+
824
+ `/company` has no site or unit to scope on, so it is narrowed to the caller's
825
+ own organisations — the fallback `getAll` already uses. `person.repo.getCompany`
826
+ gains an optional `orgs` filter; absent means no restriction, which is what
827
+ Seven365 staff get.
828
+
829
+ `GET /user/:userId` also always answers about **your own** record, whatever your
830
+ memberships say: the resident app reads it during sign-up and when resubmitting
831
+ a rejected registration.
832
+
833
+ - 6526669: Scope the resident and visitor WRITES to a site the caller can actually reach.
834
+
835
+ `PUT /api/people/id/:id` (edit), `PUT /api/people/:id` (delete),
836
+ `PATCH /api/people/:id` (approve / reject / ask to resubmit) and the exported
837
+ `suspendResidentById` identify the person by an id in the URL and carried
838
+ `requireAuth` and nothing more. Any signed-in account on the platform — a
839
+ resident, a cleaner, a guard at a different estate — could rename, delete,
840
+ approve or reject any other client's resident. #1907 closed the read
841
+ (`GET /api/people`); these are the writes behind it.
842
+
843
+ The scope is resolved from the **stored record**, never from the request, and
844
+ that is not a stylistic choice: **not one live caller sends a site.**
845
+ `layer-common usePeople.deleteById` sends no body at all,
846
+ `reviewResidentPerson` sends only `{ status, remarks }`, and both
847
+ `web-app-property-management pages/[org]/[site]/people-mgmt/index.vue:915-917`
848
+ and `components/PeopleFormMgmt.vue:618-620` explicitly `delete payload.site`
849
+ before calling `updateById`. A guard that read the site out of the body would
850
+ have refused every real caller and protected nobody.
851
+
852
+ The rule is the site-reach rule the camera, HID and people-list already use —
853
+ membership of the site, an org-wide membership at the site's owner, or an active
854
+ `customer.sites` engagement — so an agency contracted to an estate keeps
855
+ working. A record carrying an organisation but no site falls back to that
856
+ organisation. Seven365 staff are unchanged.
857
+
858
+ A resident resubmitting their own registration from the mobile app still works:
859
+ `person.service.ts:155-166` writes a `members` row pinned to the site at
860
+ sign-up, before any approval, so a rejected resident still reaches their own
861
+ site. There is an end-to-end case for exactly that.
862
+
863
+ Also: **who approved a resident now comes from the session.** It was read out of
864
+ the `user` cookie the browser sends, falling back to whatever `approvedBy` the
865
+ request body claimed, so an approval could be attributed to anybody. No caller
866
+ sends `approvedBy`, so nothing is lost.
867
+
868
+ - c7f368b: Pin what a logged-out caller may set on the resident sign-up.
869
+
870
+ `POST /api/people/resident/create` (`person.route.ts:37`) has no guard, and it
871
+ shares the `add` handler with the guarded `POST /api/people/`. The handler
872
+ spread the request body into `schemaPerson` and inserted it.
873
+
874
+ `site.people` is the collection every site-reach decision on the platform reads
875
+ its verdict from. On that schema `status` was a free-form string, and `_id`,
876
+ `user`, `approvedBy`, `type`, `site` and `org` were all caller-supplied. So an
877
+ anonymous caller could insert an APPROVED row at any client's site, pointing at
878
+ an account they control, and be reachable into that estate from then on. That is
879
+ the manufacture-access path, not a theoretical one.
880
+
881
+ It cannot be closed with a session check: the only caller anywhere in the
882
+ organisation is the resident app's registration wizard
883
+ (`app/(auth)/createUser.tsx:449`), which by definition has no session yet. So
884
+ the route is field-pinned instead.
885
+
886
+ With no session the body is now validated against a new
887
+ `schemaResidentSelfSignUp` — an explicit ALLOW-list of exactly what
888
+ `createUser.tsx` sends, with `stripUnknown`, so `_id`, `user`, `approvedBy` and
889
+ anything else are dropped rather than written. Two fields are then set
890
+ server-side rather than accepted:
891
+
892
+ - `status` is forced to `"pending"`, the literal the wizard hard-codes at
893
+ `createUser.tsx:151` and re-sets at `:237`.
894
+ - `platform` is forced to `"mobile"`. This matters as much as `status`:
895
+ `person.service.ts:130` reads it to decide whether the USER ACCOUNT it creates
896
+ starts `"pending"` or `"active"`, so a caller who sent `platform: "web"` got a
897
+ live account with nobody reviewing them.
898
+
899
+ `type` is narrowed to `resident` or `tenant` — the only two the wizard derives
900
+ (`createUser.tsx:441`) — with `.valid(...)` and `.trim().lowercase()`, never
901
+ `.allow(...)`, which accepts any non-empty string and would restrict nothing.
902
+ This closes the `user` hole properly: the service only overwrites a
903
+ caller-supplied `user` on the resident/tenant branch, so sending
904
+ `type: "contractor"` was what left a nominated `user` standing.
905
+
906
+ The guarded `POST /api/people/` is UNCHANGED and still accepts the full
907
+ `schemaPerson` — staff legitimately create contractors and visitors, set their
908
+ own statuses, and record `approvedBy`.
909
+
910
+ `site`, `org`, `block`, `level`, `unit`, `isOwner`, `email`, `password`, `name`,
911
+ `nric`, `contact` and the files are NOT pinnable: a genuine self-registration
912
+ chooses its own estate and unit, so pinning any of them would block real
913
+ residents.
914
+
915
+ Covered by `test/e2e/resident-self-signup-pinning.e2e.test.mjs`. Both create
916
+ routes are now mounted in the e2e harness, mirroring `person.route.ts`.
917
+
918
+ - f6cd1b9: Scope role writes to the role's own organisation, and reserve platform roles for Seven365 staff.
919
+
920
+ Every write on `role.controller.ts` carried `requireAuth` and nothing more, and
921
+ the role id comes from the URL. So any signed-in account on the platform — a
922
+ resident, a cleaner, a guard — could reach any organisation's roles:
923
+
924
+ - `PATCH /api/roles/permissions/id/:id` rewrote another client's role, including
925
+ its owner role. Executed against a throwaway database during the audit, an
926
+ outsider rewrote a second organisation's owner role and got a 200.
927
+ - `PATCH /api/roles/id/:id` renamed it and replaced its permissions.
928
+ - `PUT /api/roles/deleted/role` and `PUT /api/roles/:id/delete-with-reassignments`
929
+ deleted it, the second one moving its members onto other roles on the way.
930
+ - `GET /api/roles/:id/deletion-preview` listed the members holding it — another
931
+ client's staff list by another name.
932
+ - `POST /api/roles` and `POST /api/roles/v2` minted a role of `type: "admin"` —
933
+ the half of `isSuperAdmin` that lives in the roles collection.
934
+
935
+ Each of the five writes now loads the role first and authorises on the
936
+ organisation stored ON THAT ROLE, never on one the caller supplied:
937
+
938
+ - a role carrying an `org` is a tenant's, and goes through `requireOrgAccess` —
939
+ Seven365 staff, or a live member of that organisation. That is the rule the
940
+ invitation and subscription paths already use; no new mechanism is introduced.
941
+ - a role with NO `org` is a platform role — the staff console's own, and the one
942
+ `createDefaultUser` seeds — and only Seven365 staff may touch it. A missing org
943
+ is refused, not waved through.
944
+
945
+ `createRole` on both `/api/roles` and `/api/roles/v2` requires the caller to be
946
+ platform staff before it will accept `type: "admin"`. `/api/roles/v2` is a
947
+ separate mount onto a separate controller, so gating one and not the other would
948
+ have left the hole reachable by adding `/v2` to the URL.
949
+
950
+ A role the caller may not reach is reported the same as a role that does not
951
+ exist, so role ids cannot be enumerated by watching the difference.
952
+
953
+ Two things are deliberately NOT changed, and are asserted in the tests so they
954
+ read as decisions rather than oversights. `createRole` is not scoped to the
955
+ caller's own organisation, and neither are the role reads: `web-app-main
956
+ pages/sign-in.vue:311,439` and `pages/verify/email/index.vue:474` look up and
957
+ create the "Admin Service Provider" role in the INVITING organisation, which the
958
+ caller has not joined yet — that is what accepting a service-provider invitation
959
+ is, and an org-membership check there would break sign-in for every invited
960
+ provider. Scoping those needs the invitation to be consulted as well as the
961
+ membership, and is a separate change.
962
+
963
+ Every legitimate caller was checked and still works: the tenant screens
964
+ (`web-app-org pages/org/[organization]/roles-permissions.vue`, and the
965
+ `[org]/[site]/roles-permissions` screens in hygiene, security and
966
+ property-management, all through `layer-common RolePermissionMain.vue` and
967
+ `RolePermissionFormPreviewUpdate.vue`) edit roles in an organisation the operator
968
+ is a member of; the staff console (`web-app-org pages/super-admin/*` and the
969
+ retired admin app) is operated by staff and keeps cross-tenant reach.
970
+
971
+ No response shape or field changed for a permitted caller.
972
+
973
+ - 4de4685: Scope the service provider record routes to the site the engagement works on.
974
+
975
+ A `site.service-providers` row is an ENGAGEMENT: a provider organisation attached
976
+ to one client's site. Two routes took its id straight out of the URL with
977
+ `requireAuth` and nothing more, so any signed-in account could use them against
978
+ any client:
979
+
980
+ - `GET /api/service-providers/:id` returned any client's engagement — provider
981
+ name, category, contact email and the site it works on.
982
+ - `PATCH /api/service-providers/:id/status` suspended or reactivated it.
983
+ Suspending an engagement stops that provider's guards and cleaners reaching the
984
+ estate; reactivating one puts them back.
985
+
986
+ Both now decide from the **site stored on the row**, through the existing
987
+ `requireSiteReach` rule that `/api/vehicles` and `/api/documents` already use.
988
+ That rule admits both sides of the engagement: the client, and the contracted
989
+ provider whose reach comes from `customer.sites` rather than from a membership in
990
+ the client's organisation.
991
+
992
+ An engagement the caller cannot reach answers exactly as one that does not exist
993
+ (404), so ids cannot be enumerated from the difference. A row carrying no site at
994
+ all is refused, not waved through. Guards sit after each handler's Joi
995
+ validation, so a malformed id or status still answers 400 exactly as today.
996
+
997
+ Scope note — deliberately NOT changed here: the four list routes
998
+ (`GET /api/service-providers`, `/name`, `/category`,
999
+ `/org/:serviceProviderOrgId/type/:type`) and `GET /provider/:provider/org/:org`.
1000
+ Their `orgId`, `siteId` and `serviceProviderOrgId` are optional filters the
1001
+ caller sends, and `layer-common useServiceProvider.ts` defaults every one of them
1002
+ to an empty string, so it is not yet established which callers depend on the
1003
+ unfiltered list. That is the same open question as the site-less
1004
+ `GET /api/vehicles` and needs a product decision rather than a guess.
1005
+
1006
+ Nothing changes for a permitted caller: same response shape, same fields, same
1007
+ status.
1008
+
1009
+ - 89c3640: Scope the entry pass settings routes to their site, and take the writer's
1010
+ identity from the session.
1011
+
1012
+ These rows decide how a site issues visitor passes: whether passes are on at
1013
+ all, whether a physical or NFC pass is used, which USB printer model the
1014
+ gatehouse drives, and the header, disclaimer and URL printed on the pass a
1015
+ visitor is handed at the gate. All seven routes on
1016
+ `/api/site-entrypass-settings` carried `requireAuth` and nothing more — the
1017
+ controller held no authorization identifier of any kind.
1018
+
1019
+ Two defects were fixed, not one.
1020
+
1021
+ **No scope.** `GET /` returned every row on the platform: it accepts `org` and
1022
+ `site` query parameters and the repository never applies them, so a filter that
1023
+ looked like scoping was not one. The record routes took an id and no site.
1024
+ `GET|PUT /site/:site` took a site straight off the URL. All seven now go through
1025
+ `requireSiteReach`, the rule `/api/access-management`,
1026
+ `/api/manpower-monitoring`, `/api/attendances`, `/api/vehicles`,
1027
+ `/api/documents` and `/api/incident-reports` already use:
1028
+
1029
+ - `POST /` and `GET|PUT /site/:site` name a site in the request; it is checked
1030
+ before it is used.
1031
+ - `GET|PUT|DELETE /:id` name only a record, so the site is read off the
1032
+ **stored** row — a `site` in the caller's own body cannot stand in for it.
1033
+ - `PUT /:id` and `PUT /site/:site` can also **move** a row, because the body's
1034
+ `site` is `$set`, so the destination is checked as well as the origin.
1035
+ - `GET /` is narrowed after the read. It cannot be narrowed in the repository:
1036
+ the page is cached under a key built only from page/limit/status/sort, so a
1037
+ per-caller filter there would be served to the next caller. The count in the
1038
+ envelope stays the unfiltered one, as it does on the sibling lists.
1039
+
1040
+ **The writer's identity came from a cookie the caller writes.** `add`,
1041
+ `updateEntryPassSettingsById` and `updateEntryPassSettingsBySiteId` each parsed
1042
+ the raw `Cookie` header for a `user` value and stored it as `createdBy` /
1043
+ `updatedBy` without ever comparing it to the session, so the audit trail on
1044
+ these rows was whatever the caller typed. It now comes from `callerId(req)`.
1045
+ That also removes a latent crash: `cookies?.["user"].toString()` short-circuits
1046
+ only on `cookies` being nullish, so any request carrying cookies but no `user`
1047
+ cookie called `.toString()` on `undefined` and threw.
1048
+
1049
+ `requireSiteReach` matters here because the contracted agency that runs the
1050
+ gatehouse holds no membership in the estate's organisation and reaches its own
1051
+ site through `customer.sites`.
1052
+
1053
+ Nothing on this mount has a live caller: `layer-common`'s
1054
+ `useSiteEntryPassSettings` binds only `GET /:id` and no component calls it, and
1055
+ the entry-pass screens read and write `/api/access-management/settings`, a
1056
+ different mount over a different collection. The routes are guarded rather than
1057
+ removed; whether this mount should exist at all is a product decision.
1058
+
1059
+ - 67969e9: Scope `/api/sites` to the organisation that owns the site.
1060
+
1061
+ Every route on this router except the pre-login resident picker carried
1062
+ `requireAuth` and nothing more (`site.controller.ts`), and took the
1063
+ organisation from the query string or the site id from the URL. `requireAuth`
1064
+ proves a session exists and makes no organisation or site decision, so any
1065
+ signed-in account on the platform — a resident, a cleaner, an agency guard —
1066
+ could `DELETE /api/sites/:id` any site belonging to any client, rename it,
1067
+ re-block it, move its guard posts, rewrite its information page, create a site
1068
+ inside any organisation, and page through any client's estate list.
1069
+
1070
+ The caller is now resolved from the session and the decision made before
1071
+ anything is read or written:
1072
+
1073
+ - `POST /` uses `requireOrgAccess` on the body's `orgId` — that is where the new
1074
+ site lands.
1075
+ - `GET /` uses `requireOrgAccess` on the `org` filter the caller sends.
1076
+ - `PUT /:id/block`, `PATCH /id/:id`, `PATCH /guard-post/id/:id`,
1077
+ `PATCH /information/id/:id` and `DELETE /:id` use `entitleSite` — a
1078
+ membership pinned to the site, an org-wide membership at the site's owning
1079
+ organisation, or an ACTIVE `customer.sites` engagement. The engagement branch
1080
+ is required, not incidental: Site Settings lives in the SERVICE-PROVIDER apps
1081
+ (`web-app-security` and the five module apps, each
1082
+ `pages/[org]/[site]/settings.vue` via `layer-common useSiteSettings.ts`), and
1083
+ those users hold no membership in the estate's organisation at all.
1084
+ - `GET /:id` is deliberately one notch wider than the writes: it also passes
1085
+ for any live membership in the site's OWNING organisation. Three shipped
1086
+ mobile clients read this route, and the resident app reads
1087
+ `org.defaultSite` — which in a multi-site organisation need not be the
1088
+ resident's own site, and a resident's membership carries a `siteId`. The
1089
+ wider read closes the cross-CLIENT read, which is the defect, and leaves
1090
+ site-to-site partitioning inside one client where it is today.
1091
+
1092
+ A site the caller cannot reach answers exactly as a site that does not exist,
1093
+ so site ids cannot be enumerated by watching the difference.
1094
+
1095
+ `GET /site/resident` is unchanged and still pre-login: the resident mobile app
1096
+ needs a site picker before an account exists.
1097
+
1098
+ No new authorization mechanism is introduced — `requireOrgAccess` and
1099
+ `entitleSite` are the helpers the member, role, people, camera and HID paths
1100
+ already use.
1101
+
1102
+ - d27ed16: Stop the e-mail account lookup handing out the password hash and session id.
1103
+
1104
+ `GET /api/users/user/:email` (`user.route.ts:25`) has no guard on it at all, and
1105
+ `GET /api/users/email/:email` next to it is the same handler behind
1106
+ `requireAuth`. Both answered `res.json(user)` on the raw stored document, and
1107
+ the users collection stores `password` — the hash — and `sid` — that account's
1108
+ CURRENT session id.
1109
+
1110
+ So anyone at all, with no session, who knew or guessed an e-mail address got
1111
+ that account's password hash back in the response body, to crack offline at
1112
+ leisure, plus a live session id to reuse. On the authenticated twin, any signed
1113
+ in account could read the same two fields for any other account, including an
1114
+ administrator's.
1115
+
1116
+ The unguarded route cannot simply be guarded. Its only callers anywhere in the
1117
+ organisation are pre-login signup screens — the resident app's e-mail check
1118
+ (`user-registration.ts:103`) and the four `web-app-main/pages/sign-up-invite/*`
1119
+ pages through `layer-common`'s `getUserByEmailV2` — so a session check would
1120
+ break registration. The fix is therefore not authentication, it is not handing
1121
+ out more than signup asks for.
1122
+
1123
+ All five of those callers read NO field off the reply. The mobile check does
1124
+ `res !== null`; the four web pages do `if (response)`. So without a session the
1125
+ handler no longer loads the document: a new `userExistsByEmail` does a
1126
+ `findOne` with an explicit allow-list projection of `_id` alone and returns a
1127
+ boolean, and the route answers `{ exists: true }` or `null` exactly as before.
1128
+ An allow-list, deliberately — a deny-list would silently start leaking the next
1129
+ credential-shaped field somebody adds to the collection.
1130
+
1131
+ `getUserByEmail` itself is UNCHANGED and still returns the whole document,
1132
+ because signing in genuinely needs the hash to compare against. Putting the
1133
+ projection on that function instead of beside it would have broken every login.
1134
+
1135
+ With a session the reply keeps its existing shape, minus `password` and `sid`,
1136
+ via a small `withoutCredentials`. `_id` is the only field its callers read
1137
+ (`web-app-main pages/verify/email`, `web-app-org onboarding/getting-started`)
1138
+ and it survives. That path is narrowed by removal rather than by allow-list on
1139
+ purpose: it is consumed by screens across web and mobile that cannot all be
1140
+ enumerated from here, and removing two credential fields cannot break a
1141
+ legitimate one, because no screen renders a password hash or a session id.
1142
+
1143
+ The v2 twin `GET /api/users/v2/email/:email` had the same leak — it is behind
1144
+ `requireAuth`, so it was never anonymous, but "signed in" is not "entitled to
1145
+ another account's credentials" — and is stripped the same way. v2 has no
1146
+ anonymous `/user/:email` route and no pre-login caller.
1147
+
1148
+ Covered by `test/e2e/user-email-lookup-leak.e2e.test.mjs`, which asserts on the
1149
+ absence of the key and never on its value.
1150
+
1151
+ - 83b0b43: Scope the user record routes to the person themselves, or Seven365 staff — on both `/api/users` and `/api/users/v2`.
1152
+
1153
+ Five routes on each mount carried `requireAuth` and nothing more, with the target
1154
+ user id taken straight out of the URL:
1155
+
1156
+ - `GET /api/users/id/:id` returned any account's name, email, contact and NRIC.
1157
+ - `GET /api/users/organization` took the organisation from the caller's own query
1158
+ string, so any signed-in account could page through any client's whole people
1159
+ directory.
1160
+ - `PATCH /api/users/birthday/:id` rewrote any account's date of birth.
1161
+ - `PATCH /api/users/field/:id` is the sharp one. Its allow-list includes `email`,
1162
+ `nric`, `status` and `defaultOrg`, so pointing it at somebody else's id rewrote
1163
+ **their sign-in address** — and the forgot-password flow then delivers to the
1164
+ new one. That is account takeover, and `status` on the same route disables an
1165
+ account.
1166
+ - `PATCH /api/users/password/:id` verifies the current password inside the
1167
+ service, so it was an online **guessing oracle** against any account: a wrong
1168
+ current password and a mismatched confirmation answered differently.
1169
+
1170
+ `/api/users/v2` is a separate controller file (`user-v2.controller.ts`) with the
1171
+ same routes and the same five holes, so the guard was added to
1172
+ `console-authz.util.ts` as `requireSelfOrPlatformStaff` and **both** controllers
1173
+ call it — rather than fixing v1 and leaving its twin open.
1174
+
1175
+ - the four id-in-URL routes require the id to be the caller's own, or the caller
1176
+ to be Seven365 staff.
1177
+ - `GET /api/users/organization` puts the caller-supplied organisation through
1178
+ `requireOrgAccess` before it is used, the same way `GET /api/members` does.
1179
+
1180
+ Guards sit after each handler's Joi validation, so a malformed id or a malformed
1181
+ value still answers 400 exactly as it does today.
1182
+
1183
+ Every caller in the org already satisfies this rule: the account app's
1184
+ personal-info screens, the resident mobile app's profile editor, and the sign-in
1185
+ and verify-email flows all pass the caller's own id. The one screen that edits
1186
+ another person, `layer-common ClientDetailForm.vue:370`, is reached only from
1187
+ `web-app-org pages/super-admin/client-list.vue` — the Seven365 staff console —
1188
+ which the staff branch covers.
1189
+
1190
+ Nothing changes for a permitted caller: same response shape, same fields, same
1191
+ status.
1192
+
1193
+ - 284c83d: Restrict the unauthenticated invitation-status write to the one transition its
1194
+ callers actually perform.
1195
+
1196
+ `PATCH /api/auth/verification/status` (`API-core auth.route.ts:36`) carries no
1197
+ middleware, and it cannot be given any: every caller is an invitation-landing
1198
+ page opened from an emailed link **before an account exists** —
1199
+ `web-app-main` `pages/verify/invitation/[id].vue`,
1200
+ `pages/verify/service-provider/[id].vue`,
1201
+ `pages/verify/service-provider-invite/[id].vue` and
1202
+ `pages/sign-up-invite/service-provider/index.vue`, all through
1203
+ `layer-common composables/useLocalAuth.ts:130`. Adding `requireAuth` would break
1204
+ invitation acceptance for everybody, including the shipped clients.
1205
+
1206
+ What is restricted instead is what an anonymous caller may _do_. Each of those
1207
+ pages calls `GET /auth/verify/:id` first, and `verify`
1208
+ (`verification.service.ts:661-690`) already refuses an invitation that is
1209
+ expired, cancelled or complete — so the only transition a real caller ever
1210
+ performs is `pending -> complete`. Accepting the other three states let anyone
1211
+ who learned an invitation id:
1212
+
1213
+ - set a used invitation back to `pending` and replay it,
1214
+ - revive a `cancelled` or `expired` invitation, or
1215
+ - burn somebody else's pending invitation by marking it `complete` before they
1216
+ opened the email.
1217
+
1218
+ The handler now accepts only `complete`, loads the invitation and requires it to
1219
+ be `pending`. Completing an already-complete invitation still answers 200 so a
1220
+ page that calls this twice is not broken. Cancelling an invitation on purpose
1221
+ keeps its own session-protected endpoint,
1222
+ `PUT /api/verifications/:id/cancel` (`verification.route.ts:13`), so no
1223
+ capability is removed.
1224
+
1225
+ - ed0557c: Scope the work order routes to the estate the job was raised at, and take the
1226
+ author's identity from the session.
1227
+
1228
+ A work order is a job at one estate: the subject, the description, the block and
1229
+ unit it sits in, the attachments, the assigned provider, and the name and
1230
+ signature captured when it is signed off. Every route on `/api/work-orders`
1231
+ carried `requireAuth` and nothing more, and `work-order.controller.ts` held no
1232
+ authorization identifier of any kind. `requireAuth` proves a session exists; it
1233
+ makes no organisation or site decision. So being signed in ANYWHERE on the
1234
+ platform was enough to read another client's jobs, rewrite them, drive them
1235
+ between columns, sign them off in a stranger's name, or delete them.
1236
+
1237
+ `DELETE /:id` is the sharp one, and for the same reason it was sharp on
1238
+ `/api/feedbacks`: **it does not write to the v1 table at all.** `deleteWorkOrder`
1239
+ in `work-order.repo.ts` updates `workOrders2Collection` — `work-orders2`, the
1240
+ authoritative table every client actually reads. A guard written against the v1
1241
+ row would have been the wrong guard, so this one is written against the row the
1242
+ write really touches. `DELETE /:id` and `PUT /:id` are also the only two routes
1243
+ on this mount with a live caller: `layer-common`'s
1244
+ `useWorkOrder().deleteWorkOrder` and `updateWorkOrder`, behind the shared
1245
+ `WorkOrder/Main.vue` and `WorkOrder/Detail.vue` screens that every web app draws.
1246
+
1247
+ All seven handlers now decide before any work is done:
1248
+
1249
+ - `GET /site/:site/status/:status` names the site in the URL, so it is checked
1250
+ directly with `requireSiteReach`.
1251
+ - `GET /:id`, `PUT /:id`, `PUT /:id/:status` and `PATCH /:id/completed` name only
1252
+ a record, so the site is read off the STORED row through the new
1253
+ `requireWorkOrderReach`. A `site` in the caller's own body or query cannot
1254
+ stand in for it.
1255
+ - `DELETE /:id` uses `requireWorkOrder2Reach`, against the `work-orders2` row.
1256
+ - `POST /` reads the destination from the body.
1257
+
1258
+ Two readers were needed, not one, because the tables are not the same shape: v1
1259
+ `work-orders` rows keep the site at `metadata.site`, `work-orders2` rows keep it
1260
+ at the top level. Both are new raw readers on the repository —
1261
+ `getRawWorkOrderById` and `getRawWorkOrder2ById`. The existing `getWorkOrderById`
1262
+ is CACHED and shaped for the screen, so a reach check built on it would have
1263
+ decided who may touch a job from a cache entry.
1264
+
1265
+ Two further defects fixed in the same pass:
1266
+
1267
+ 1. **Identity from a cookie the caller writes.** `POST /` parsed `createdBy` out
1268
+ of the raw `Cookie` header and never compared it to the session, so a job
1269
+ could be raised in somebody else's name. It comes from `callerId(req)` now.
1270
+ That also closes a latent crash: `cookies?.["user"].toString()`
1271
+ short-circuits only on `cookies` being nullish, so any request carrying
1272
+ cookies but no `user` cookie threw a TypeError.
1273
+ 2. **A body that names the site twice, with unequal weight.** `POST /` accepts
1274
+ `metadata.site` AND a top-level `site`, and `work-order.service.ts` copies the
1275
+ top-level one OVER `metadata.site` (`if (value.site) value.metadata.site =
1276
+ value.site`). A caller could therefore name their own estate in `metadata`
1277
+ while the row landed at somebody else's. The guard reads them in the order the
1278
+ service resolves them.
1279
+
1280
+ `PUT /:id` is a write that can MOVE a record — the repository `$set`s whatever
1281
+ the body carries, including `site` — so the DESTINATION is checked as well as the
1282
+ origin. Without that, a legacy row carrying no `metadata.site` could be given a
1283
+ top-level `site` of the caller's own and become theirs.
1284
+
1285
+ Each guard is placed AFTER the handler's own Joi validation, so a malformed id
1286
+ still answers 400 exactly as before. A row that does not exist has no site, and
1287
+ `requireSiteReach` refuses a missing site, so an id matching nothing is refused
1288
+ rather than falling through.
1289
+
1290
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`, already used
1291
+ by `/api/feedbacks`, `/api/manpower-monitoring`, `/api/attendances`,
1292
+ `/api/remarks`, `/api/vehicles`, `/api/documents` and `/api/incident-reports`.
1293
+ The `customer.sites` branch is required, not incidental: the contracted provider
1294
+ that does this work holds no membership in the estate's own organisation.
1295
+
1296
+ `/api/work-orders2` is a different mount, lives in API-core's own controller, and
1297
+ is not touched here.
1298
+
1299
+ ### Patch Changes
1300
+
1301
+ - 8a056c0: Scope the attendance settings routes to their estate. `GET` and `PUT /api/attendance-settings/site/:site` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could read and rewrite any estate's attendance settings — turn location capture off, move the clock-in coordinates, or widen the geofence radius so a shift can be clocked in from anywhere. The read also created a default settings row for a site that had none, so an unauthorised read wrote into another estate's data. Reach is now decided from the session, on the site named in the request, before either the read or the write happens.
1302
+ - 1c8c481: Scope the bulletin-board routes to the caller's own estate.
1303
+
1304
+ All five `/api/bulletin-boards` routes carried `requireAuth` and nothing more,
1305
+ and the controller held no authorization identifier of any kind, so any
1306
+ signed-in account in any organisation could publish an announcement into another
1307
+ client's estate — which also fans out as a notification to every recipient group
1308
+ at that site — read that board, and read, rewrite or delete any single bulletin
1309
+ reached by id alone.
1310
+
1311
+ `add` and `getAll` now authorise the site named in the request with
1312
+ `requireSiteReach`; `site` is optional on the add schema, so the org-wide case is
1313
+ asked about separately rather than an absent site leaving the write unscoped. The
1314
+ three by-id handlers take the verdict off the STORED row with a new module-named
1315
+ `requireBulletinReach`, which answers "not yours" and "does not exist"
1316
+ identically so bulletin ids cannot be enumerated.
1317
+
1318
+ - 4fbd0aa: Scope the bulletin video routes to their estate. `POST /api/bulletin-videos`, the list, the by-id read, the update and the delete carried `requireAuth` and nothing more, so any signed-in account on the platform could publish a video into another client's estate, list that estate's videos, or read, rewrite and delete any video by its id alone. Reach is now decided from the session — on the site named in the request for the write and the list, and on the stored row for the three by-id routes.
1319
+ - b6cea8b: Classify a camera by its `type` correctly, so an ANPR unit can be switched off.
1320
+
1321
+ Reported as `.github-private#35486`: a faulty ANPR camera at a client site is
1322
+ down in PRODUCTION, and `PATCH /api/site-cameras/id/:id` with
1323
+ `status: "inactive"` answers `"You do not have permission to set up cameras on
1324
+ this site."` The camera is broken and the person trying to switch it off is
1325
+ locked out.
1326
+
1327
+ The refusal is ours. `cameraManagePermissions` (`camera-view.util.ts`) picked
1328
+ between the two Settings panels' permission strings with
1329
+ `String(type) === "anpr"`, on the strength of a comment claiming `type` was
1330
+ "validated to `ip | anpr` by the model". It was not. `site-camera.model.ts` used
1331
+ `Joi.string().allow("ip", "anpr").required()`, and `.allow()` WHITELISTS
1332
+ additional values rather than restricting to them. Run against joi 17.13.3 the
1333
+ rule accepts `"ANPR"`, `"Anpr"` and `"totally-arbitrary-xyz"` exactly as readily
1334
+ as `"anpr"`; only `""`, `undefined`, `null` and non-strings were refused, and
1335
+ those by `string().required()`, not by the list. So a stored row could carry a
1336
+ spelling the gate could not place, an ANPR unit was classed as CCTV, and the
1337
+ write demanded `site-settings:manage-cctv-camera` — which a role provisioned for
1338
+ the ANPR panel does not hold.
1339
+
1340
+ Two changes, and the gate itself is untouched in substance:
1341
+
1342
+ - The schema is now `Joi.string().trim().lowercase().valid("ip", "anpr")`, so
1343
+ nothing outside the pair can be stored again. `.trim().lowercase()` runs
1344
+ before the check, so a caller sending `"ANPR"` is normalised rather than newly
1345
+ refused; every client in the estate already sends the lower-case pair.
1346
+ `MSiteCamera` normalises the value it writes for the same reason.
1347
+ - `cameraManagePermissions` compares case- and whitespace-insensitively instead
1348
+ of exactly, because the rows already in the collection were written under the
1349
+ old rule and `loadForWrite` reads them raw, so the mapper's default never
1350
+ reaches them. A `type` that still cannot be placed — absent, or a legacy value
1351
+ — now asks for EITHER permission. The two strings are two panels, not two
1352
+ privilege levels; neither is the safer guess, and guessing one is what locked
1353
+ the correct operator out.
1354
+
1355
+ A caller must still be entitled to the site AND hold a camera-management
1356
+ permission on the membership that granted it, so an unrelated tenant is refused
1357
+ exactly as before, and none of the pre-`62e4eb62` behaviour — any signed-in
1358
+ account rewriting or hard-deleting any organisation's cameras — returns. No
1359
+ caller who could write a camera before is refused now: the unplaceable case is a
1360
+ superset of the CCTV string it used to demand.
1361
+
1362
+ - 991e0a1: Scope the event-management routes to the caller's own estate.
1363
+
1364
+ All five `/api/event-management` routes carried `requireAuth` and nothing more,
1365
+ and the controller held no authorization identifier of any kind, so any
1366
+ signed-in account in any organisation could file an event into another client's
1367
+ calendar, read that calendar, and read, rewrite or delete any single event
1368
+ reached by id alone.
1369
+
1370
+ `add` and `getAll` now authorise the site named in the request with
1371
+ `requireSiteReach`; the three by-id handlers take the verdict off the STORED row
1372
+ with a new module-named `requireEventReach`, which answers "not yours" and "does
1373
+ not exist" identically so event ids cannot be enumerated.
1374
+
1375
+ - f2755a1: Scope the facility booking routes to the estate. `/api/facility-bookings` carried
1376
+ `requireAuth` and nothing else on all five routes: any signed-in account could book
1377
+ a facility at another estate, list another client's bookings, and read, rewrite or
1378
+ cancel any booking by id alone — including moving a booking to a different estate.
1379
+ Reach is now checked on the site named in the request, and on the stored row where
1380
+ the request names only an id.
1381
+ - ddac2d2: Scope the form-entry routes to the estate and to the resident who submitted them
1382
+
1383
+ `/api/form-entries` is a second controller over the same collection as
1384
+ `/api/online-forms`, and the rows in it are not templates only - a resident's
1385
+ submission lives there too. Three of its seven routes carried no session check
1386
+ at all, and the other four carried `requireAuth` and nothing more with no
1387
+ authorization identifier in the controller.
1388
+
1389
+ `GET /resident` took `org`, `site` and `userId` from the query string and
1390
+ matched on exactly those with no projection, so an unauthenticated caller who
1391
+ could name a resident's user id got that resident's whole submission history.
1392
+ It now answers only for the caller themselves, or for somebody who reaches the
1393
+ estate being asked about.
1394
+
1395
+ The reads, the update and the archive are decided by `requireFormEntryReach`,
1396
+ on the STORED row: the estate through `entitleSite`, or the person whose
1397
+ `userId` the row carries - residents hold no `members` document, so the
1398
+ owns-it branch is required or every resident loses their own submissions. The
1399
+ list closes both halves of its optional `site`. The update authorises its
1400
+ destination as well as its source, drops `userId` so ownership cannot be
1401
+ reassigned, and stops a resident approving their own application while leaving
1402
+ resubmission working. `POST /resident` stamps `userId` from the session instead
1403
+ of the body, and `add` stamps `createdBy` from the session instead of the
1404
+ caller's own cookie (which also removes a crash for any request sending cookies
1405
+ without a `user`).
1406
+
1407
+ - 584bce4: Constrain what a HID configuration write may contain, and stop storing password salts
1408
+
1409
+ `PUT /readers/:readerId/configuration` is a passthrough to the reader's
1410
+ `set_configuration.fcgi`, and its body schema was `Joi.object().unknown(true)`.
1411
+ That reaches `monitor.hostname`, `monitor.port` and `monitor.path` — the block
1412
+ that decides which server receives every identification at that door — so one
1413
+ request could redirect a reader's entire event stream. Writes are now limited to
1414
+ the two modules the product actually writes, `pjsip` and `identifier`, which are
1415
+ the only two `setReaderConfiguration` call sites in `layer-common`. Module
1416
+ contents stay unvalidated (the vendor owns those keys), reading configuration is
1417
+ unchanged, and the monitor block is still managed by the server through
1418
+ `setReaderMonitor` and `configureReaderIntegration`, which do not use this
1419
+ schema.
1420
+
1421
+ `sanitizeHidPayload` — the central redaction every `addEvent` already passes
1422
+ through — did not include `salt`, so a password salt was still being persisted
1423
+ beside the hash it belongs to. `salt`, `salt_value` and `saltvalue` are now
1424
+ redacted like the rest. This does not touch records already stored.
1425
+
1426
+ - 65eda12: Scope the four manpower-attendance reads to the caller's sites and the attendance permission.
1427
+ - a77e9fb: Scope a subscription's invoices to the subscription's owner.
1428
+
1429
+ `GET /api/invoices/subscription/:id` carried `requireAuth` and nothing more, and
1430
+ `invoice.controller.ts` held no authorization identifier of any kind: the
1431
+ subscription id came off the URL and went straight into the query. Being signed
1432
+ in anywhere on the platform was therefore enough to page through another
1433
+ organisation's billing history — invoice numbers, amounts, line-item
1434
+ descriptions, due dates and payment status.
1435
+
1436
+ A new module-named `requireInvoiceReach` takes the verdict off the STORED
1437
+ subscription, never from anything the caller supplied. A subscription is owned
1438
+ by an organisation OR by a single user, so both are checked: `siteReachOf`'s
1439
+ `canReach` answers the organisation half, and the personal half is the caller
1440
+ being that user. A subscription that names neither is refused. "Not yours",
1441
+ "does not exist" and "not a valid id" all answer 404 identically, so
1442
+ subscription ids cannot be enumerated.
1443
+
1444
+ Read access to invoice records only — no card handling, no payment gateway and
1445
+ no money movement is touched.
1446
+
1447
+ - 5235660: Scope the manpower designation routes to the estate, and stamp who filed the plan
1448
+ from the session. `/api/manpower-designations` carried `requireAuth` and nothing
1449
+ else on all three routes: any signed-in account could file a manning plan into
1450
+ another estate, read any estate's guard-strength plan, or rewrite one by id alone.
1451
+ `createdBy` and `createdByName` were taken from the request body, so the plan could
1452
+ be attributed to anybody. Reach is now checked on the site named in the request and
1453
+ on the stored row for the update, and the two identity fields come from the session.
1454
+ - 8223c73: Stop the every-minute manpower-sites sync from deleting the whole catalogue when the HRM Labs lookup returns nothing.
1455
+ - 62f5fa4: Treat a maximum seat count of 0 as unlimited when an organisation sends an
1456
+ invitation. `createOrganizationInvite` read the subscription's `maxSeats` through
1457
+ `?? 0` and then refused on any falsy value, so an organisation configured with 0
1458
+ seats — which the plan dialog offers and the console labels UNLIMITED — had every
1459
+ invitation rejected with "No seats configured for organization." 0 now admits
1460
+ invitations without a cap, a real limit is still enforced at the limit, and only a
1461
+ genuinely absent or non-numeric value is still treated as a misconfiguration.
1462
+ - f4c8bdb: Scope the online form routes to the estate
1463
+
1464
+ All six `/api/online-forms` routes carried `requireAuth` and nothing more, so any
1465
+ signed-in account on the platform could read, rewrite, move or archive another
1466
+ estate's form templates. The controller now takes its verdict from the session:
1467
+ a new `requireOnlineFormReach` for the by-id read, update and delete, a site
1468
+ check on the add and on the by-site list, both tenancy filters closed on the
1469
+ list query, and the update's destination `site` checked as well as the source.
1470
+ `createdBy`/`updatedBy` are stamped from the session instead of the caller's
1471
+ `user` cookie, and `getOnlineFormsBySiteId` now actually applies the estate
1472
+ filter it was given.
1473
+
1474
+ - 1f5cba2: Return the organisation's name only on the anonymous organisation lookup.
1475
+
1476
+ `GET /api/organizations/org/id/:id` is the unguarded twin of `/id/:id`, and it
1477
+ shares this controller's `getById`. The handler answered `res.json(data)` on the
1478
+ raw stored organisation document, which carries the client's own business
1479
+ `email` and `contact` number alongside `description`, `busInst`, `nature`,
1480
+ `defaultSite`, `terms` and `policies`. So anyone at all, with no session, who
1481
+ knew or guessed an organisation id got a client's business contact details back.
1482
+
1483
+ It cannot be given a guard. Its only callers anywhere in the organisation are
1484
+ `web-app-main`'s `sign-up-invite/onboarding-admin` and
1485
+ `sign-up-invite/service-provider` pages (via `layer-common`'s
1486
+ `useOrg.getOrgById`), reached from an invite e-mail before the account being
1487
+ invited exists. Both pages do the same thing with the reply:
1488
+ `form.organization = org?.name || ""` — they read `name` and nothing else.
1489
+
1490
+ So the anonymous path now uses a new `getNameById` on the organisation
1491
+ repository: `findOne` with an **allow-list** projection of `_id` and `name`. A
1492
+ field added to the organisation document later is excluded by default rather
1493
+ than leaking on the day it is added.
1494
+
1495
+ `getById` itself is untouched. About twenty callers inside this package need the
1496
+ whole document — `customer`, `feedback`, `member`, `person`, `robot`,
1497
+ `service-provider`, `service-provider-invite`, `site` and `user-v2` services all
1498
+ read it — so projecting that function would have broken them. The new function
1499
+ sits beside it, and the controller picks between the two on whether the request
1500
+ carries a session (`callerId(req)`), which is the same shape used for the
1501
+ pre-login user lookup and resident-create routes. The guarded `/id/:id` route is
1502
+ therefore unchanged: still the whole document, still 401 without a session.
1503
+
1504
+ - 30b0739: Scope the patrol log email route to its estate. `POST /api/patrol-logs/email` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could name another client's site and that site's patrol log ids and have the report emailed — to a list of up to 25 addresses of their own choosing, from the platform's sending address. The service's own log-belongs-to-site check is internal consistency, not reach: name the other estate's site and its own logs and the two agree. Reach is now decided from the session, on the site named in the request body, before anything is read, recorded, rendered or sent. `createdBy` — the audit row for who emailed patrol logs out of the system — is taken from the session rather than from the request body.
1505
+ - 4a3f19e: Scope the virtual patrol log routes to the estate, and take the author from the session
1506
+
1507
+ `/api/patrol-logs` carried `requireAuth` and nothing more on all five handlers,
1508
+ and `patrol-log.controller.ts` held no authorization identifier of any kind. A
1509
+ patrol log is the proof a round was walked, so any signed-in account on the
1510
+ platform could read another client's patrol history, rewrite a round's outcome,
1511
+ detach its incident report, move it to another estate, or destroy it. The delete
1512
+ is a HARD delete (`collection.deleteOne`), so there is no tombstone to notice.
1513
+
1514
+ `GET /` was worse than "unscoped": `site` was an OPTIONAL query parameter and
1515
+ the repository only applies it when it is a valid id, so a request that simply
1516
+ omitted it was answered with EVERY estate's patrol history in one page. `site`
1517
+ is now required, which is what both live callers already send.
1518
+
1519
+ `createdBy` was accepted from the request body and never compared to the
1520
+ session, so a round could be filed under another guard's name. It now comes from
1521
+ `callerId(req)`.
1522
+
1523
+ Adds `requirePatrolLogReach`, built on the existing `requireSiteReach` rule,
1524
+ plus an uncached `getRawPatrolLogById` so the check is not decided from a
1525
+ 15-minute cache entry. `PUT /id/:id` checks BOTH ends, because
1526
+ `schemaUpdatePatrolLog` accepts `site` and `updateById` writes it.
1527
+
1528
+ Behaviour change: `GET /id/:id` used to answer 200 with `data: null` for an id
1529
+ matching nothing; it now refuses.
1530
+
1531
+ - f8e379b: Scope the virtual patrol question routes to the estate
1532
+
1533
+ `/api/patrol-questions` carried `requireAuth` and nothing more on all four
1534
+ handlers, and `patrol-question.controller.ts` held no authorization identifier
1535
+ of any kind. Any signed-in account on the platform could add a question to
1536
+ another client's estate, reword one, change the answers it accepts, or delete
1537
+ it. The reword is the quiet one: the guard's console still shows a question and
1538
+ still records an answer, so nothing looks wrong, but the estate is no longer
1539
+ checking what it believes it is checking.
1540
+
1541
+ `GET /` was worse than "unscoped": `site` was an OPTIONAL query parameter and
1542
+ the repository only applies it when it is a valid id, so a request that simply
1543
+ omitted it was answered with EVERY estate's patrol questions in one page.
1544
+ `site` is now required, which is what both live callers already send.
1545
+
1546
+ Adds `requirePatrolQuestionReach`, built on the existing `requireSiteReach`
1547
+ rule, plus an uncached `getRawPatrolQuestionById` so the check is not decided
1548
+ from a 15-minute cache entry.
1549
+
1550
+ - 6a726f6: Scope the virtual patrol route routes to the estate
1551
+
1552
+ `/api/patrol-routes` carried `requireAuth` and nothing more on all six
1553
+ handlers, and `patrol-route.controller.ts` held no authorization identifier of
1554
+ any kind. Any signed-in account on the platform could read another client's
1555
+ patrol schedule, re-time it, re-assign it, point it at different cameras, move
1556
+ it to another estate, or delete it outright — and a deleted round simply stops
1557
+ appearing in `scheduled-routes`, so nobody is told to walk it and nothing looks
1558
+ wrong on the console.
1559
+
1560
+ `GET /` and `GET /scheduled-routes` were worse than "unscoped": `site` was an
1561
+ OPTIONAL query parameter and the repository only applies it when it is a valid
1562
+ id, so a request that simply omitted it was answered with EVERY estate's patrol
1563
+ schedule in one page. `site` is now required, which is what both live callers
1564
+ already send.
1565
+
1566
+ Adds `requirePatrolRouteReach`, built on the existing `requireSiteReach` rule,
1567
+ plus an uncached `getRawPatrolRouteById` so the check is not decided from a
1568
+ 15-minute cache entry. `PUT /id/:id` checks BOTH ends, because
1569
+ `schemaUpdatePatrolRoute` accepts `site` and `updateById` writes it.
1570
+
1571
+ Behaviour change: `GET /id/:id` used to answer 200 with `data: null` for an id
1572
+ matching nothing; it now refuses.
1573
+
1574
+ - e504691: Make the people search filters narrow each other instead of overwriting each
1575
+ other.
1576
+
1577
+ `person.repo.getAll` built its Mongo filter as one object literal in which four
1578
+ different filters each wrote a `$or` key — the date range (`dateTo`), the
1579
+ free-text `search`, `contact` and `plateNumber`. An object literal can only
1580
+ carry one `$or`, so the last one written silently replaced every earlier one.
1581
+ In practice:
1582
+
1583
+ - searching by name **and** contact number searched only the contact number,
1584
+ - searching by name **and** plate number searched only the plate number,
1585
+ - and a **date range was dropped entirely** the moment any of the three was
1586
+ used — which is the live case, because `layer-common`
1587
+ `composables/usePeople.ts:2-30` sends `search`, `dateFrom` and `dateTo`
1588
+ together from the visitor/people list.
1589
+
1590
+ No error was raised; the endpoint simply answered a narrower question than it
1591
+ was asked, with rows that should not have matched.
1592
+
1593
+ The four groups are now collected and combined with `$and`, so each one narrows
1594
+ the result. A single filter behaves exactly as before, and a request with no
1595
+ filters is unchanged.
1596
+
1597
+ - eec7708: Scope the service provider billing routes to the organisation
1598
+
1599
+ `/api/provider-billings` carried `requireAuth` and nothing more on all five
1600
+ handlers, and `service-provider-billing.controller.ts` held no authorization
1601
+ identifier of any kind. A service provider billing record is what a contractor
1602
+ invoices an organisation for, so any signed-in account on the platform could
1603
+ read another organisation's invoices, swap the attachments that evidence one,
1604
+ flip its status, add an acknowledgement that was never given, or delete it.
1605
+
1606
+ The scope here is the ORGANISATION, not the estate: every row carries `orgId`
1607
+ and no `site`, so the rule is `requireOrgAccess` — Seven365 staff, or a live
1608
+ member of that organisation — not `requireSiteReach`.
1609
+
1610
+ Adds `requireServiceProviderBillingReach`, plus an uncached
1611
+ `getRawServiceProviderBillingById` so the check is not decided from a 15-minute
1612
+ cache entry (the existing reader is cached and throws `NotFoundError`, which
1613
+ would also answer before the guard ran).
1614
+
1615
+ - 0c167e6: Scope the robot routes to their estate. `POST /api/robots` and `GET /api/robots/organization/:organization/site/:site` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could page any estate's robot list — which projects `appKey` and `appToken`, the robot's credentials — and aim a robot registration at any estate. Reach is now decided from the session, on the site named in the request. A registration naming no estate is refused rather than falling through unscoped, and `createdBy` is taken from the session rather than from the request body.
1616
+ - 88000a3: Scope the site billing configuration routes to the estate, and take createdBy from the session
1617
+ - 477139d: Scope the site billing item routes to the estate, and check the unit list against the item's own site
1618
+ - 1f71a3e: Scope the site facility routes to the estate. `/api/site-facilities` carried
1619
+ `requireAuth` and nothing else on all six routes: any signed-in account could add a
1620
+ facility to another estate, list another client's facilities — or, by omitting the
1621
+ optional `site` filter, every estate's facilities at once — and read, rewrite or
1622
+ archive any facility by id alone, including its opening hours, booking fees, deposit
1623
+ and policies. Reach is now checked on the site named in the request, and on the
1624
+ stored row where the request names only an id; the unfiltered cross-estate list is
1625
+ left to platform staff.
1626
+ - 97a5e71: Scope the statement of account routes to the estate and the unit, and take the requester and approver from the session
1627
+
1628
+ Eight of the nine routes on `/api/site-soa` carried `requireAuth` and nothing
1629
+ more, and `site-soa.controller.ts` held no authorization identifier of any kind.
1630
+ A statement of account is one unit's ledger for a period, so any signed-in
1631
+ account on the platform could read another estate's statements, restate one,
1632
+ approve or reject it, move it to another estate, or delete it.
1633
+
1634
+ `GET /user` is named for the signed-in resident but took `site` and `unitId`
1635
+ from the query and identified the caller not at all, so a resident could read a
1636
+ neighbour's statement of account by changing one id in a URL. It now requires
1637
+ `unitId` and checks it against a live `people` row joining the caller to that
1638
+ unit, via `requireOwnUnit`.
1639
+
1640
+ `add` set `createdBy`, and `reviewSOA` set `approvedBy._id`, by parsing the raw
1641
+ `user` cookie off the request, so a statement could be requested under another
1642
+ resident's name and approved under another person's. Both now come from
1643
+ `callerId(req)`.
1644
+
1645
+ Adds `requireStatementReach` plus an uncached `getRawStatementOfAccountById`.
1646
+ `PUT /id/:id` checks both ends, because `schemaUpdateStatementOfAccount` accepts
1647
+ `site` and `updateById` writes it.
1648
+
1649
+ `GET /generate-pdf` is deliberately unchanged: it is the platform's PDF service,
1650
+ called from every web app by `@7365admin1/layer-common`'s `usePDFDownload`, and
1651
+ it is authorized by forwarding the caller's own session to the headless browser.
1652
+
1653
+ - dd01b3e: Scope the unit billing routes to the estate, and the resident routes to the caller's own unit
1654
+
1655
+ `/api/site-unit-billing` carried `requireAuth` and nothing more on all seven
1656
+ handlers, and `site-unit-billing.controller.ts` held no authorization identifier
1657
+ of any kind. Two separate problems followed.
1658
+
1659
+ On the estate side, any signed-in account could add a bill to any estate, read
1660
+ another estate's whole billing run, restate what a resident owes, move a bill to
1661
+ another estate, or delete it. Those five routes now use `requireSiteReach`, plus
1662
+ a new `requireUnitBillingReach` on the stored row and an uncached
1663
+ `getRawUnitBillingById`. `PUT /id/:id` checks both ends, because
1664
+ `schemaUpdateSiteUnitBilling` accepts `site` and `updateById` writes it.
1665
+
1666
+ On the resident side, `GET /user` and `GET /user/unsettled` are named for the
1667
+ signed-in resident but took `site` and `unitId` from the QUERY and identified the
1668
+ caller not at all, so they were never "my bills" — they were any unit's bills at
1669
+ any estate. A resident passing a neighbour's unit id got the neighbour's
1670
+ maintenance bills, and omitting `unitId` dropped that filter too and returned the
1671
+ whole estate's. Both routes now require `unitId` and check it against a live
1672
+ `people` row joining the caller to that unit at that site, via a new
1673
+ `requireOwnUnit`. Seven365 platform staff are exempt.
1674
+
1675
+ - c1d9d07: Let Seven365 staff switch a client's faulty camera off, and record it
1676
+
1677
+ `PATCH /api/site-cameras/id/:id` now falls back to a Seven365 staff check when
1678
+ the ordinary site rule refuses. The fallback writes `status` only and is
1679
+ recorded in the console audit trail. Reads, `add` and `deleteById` are
1680
+ unchanged, so staff have gained no access to any organisation's camera list.
1681
+
1682
+ - 7ec7f3a: Check the vehicle-approval permission under the resource name the catalogues
1683
+ actually grant.
1684
+
1685
+ When a security agency adds a vehicle, the vehicle is parked at `PENDING` unless
1686
+ the caller may approve one. `vehicle.service.ts:125` asked for
1687
+ `vehicleManagement:approve-vehicle`, but every catalogue that can grant a
1688
+ permission spells that resource `vehicle-mgmt` — `web-app-security`
1689
+ `composables/useSecurityPermission.ts:87,111`, `web-app-org`
1690
+ `composables/usePropertyPermission.ts:312`, `ma-mobile-app`
1691
+ `src/features/permissions/permission.catalog.ts:7`, `isecure365-mobile-app`
1692
+ `src/features/permissions/permission.catalog.ts:32`. Only the prefix differed,
1693
+ so the string checked here was one no role could hold and the bypass could never
1694
+ be reached by granting the permission — the agency's vehicles stayed pending
1695
+ however the role was set up. `vehicleManagement:` appears nowhere else in any
1696
+ repository in the organisation.
1697
+
1698
+ The `*` wildcard branch is untouched, so a role holding all permissions behaves
1699
+ exactly as before; this only makes the specific grant work as intended.
1700
+
1701
+ - 772cd89: Scope the visitor guest routes to the estate
1702
+
1703
+ `/api/visitor-guests` carried `requireAuth` and nothing more on all four
1704
+ routes, so any signed-in account could add, read, edit or delete another
1705
+ estate's expected-visitor list. `site` was optional on the list query and the
1706
+ repository only applies it when valid, so omitting it returned every estate's
1707
+ visitor records at once. All four handlers now ask the existing site-reach
1708
+ rule, and `site` is required on the list query.
1709
+
3
1710
  ## 3.48.0
4
1711
 
5
1712
  ### Minor Changes