@7365admin1/core 3.49.1-staging.216 → 3.50.1-staging.217
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.
- package/.changeset/site-camera-enum-validation.md +5 -0
- package/CHANGELOG.md +338 -0
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/site-camera-enum.test.mjs +84 -0
- package/.changeset/access-management-record-scope.md +0 -48
- package/.changeset/hrmlabs-attendance-location-name.md +0 -35
- package/.changeset/invite-org-scope.md +0 -38
- package/.changeset/organization-and-promo-list-scope.md +0 -39
- package/.changeset/resident-ownership-scope.md +0 -5
- package/.changeset/revoke-sessions-when-access-is-taken-away.md +0 -63
- package/.changeset/role-identity-status-and-seeded-role.md +0 -57
- package/.changeset/role-member-org-scope.md +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,343 @@
|
|
|
1
1
|
# @iservice365/core
|
|
2
2
|
|
|
3
|
+
## 3.50.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ee5e7dd: Scope the access-management routes that name a card, not a site.
|
|
8
|
+
|
|
9
|
+
`access-management-site-scope` closed the routes whose request already carries
|
|
10
|
+
an estate. Seven routes on the same mount carry none — they reach an access card
|
|
11
|
+
(or a unit) by `_id` and nothing else — so there was nothing for that rule to
|
|
12
|
+
read and they were left carrying `requireAuth` alone:
|
|
13
|
+
|
|
14
|
+
- `POST /sign-qr` — mints a working door credential. The card number is
|
|
15
|
+
RSA-encrypted into a QR payload the reader accepts for up to 24 hours when
|
|
16
|
+
`purpose` is `"vms"`. Any signed-in account could hand itself a valid pass for
|
|
17
|
+
any card in any estate on the platform.
|
|
18
|
+
- `PATCH /delete-card` — deactivates a card, which is what stops it opening a
|
|
19
|
+
door.
|
|
20
|
+
- `PATCH /nfc-status` — flips a card's status and activation.
|
|
21
|
+
- `PATCH /visitor-checkout` — releases a visitor's card and writes its history.
|
|
22
|
+
- `POST /card-replacement` — retires a card and hands its replacement to a unit.
|
|
23
|
+
- `POST /assign-user` — hands the cards on a unit to a list of people.
|
|
24
|
+
- `POST /settings` — rewrites an estate's access-management settings, including
|
|
25
|
+
the address of the Entrypass controller it talks to. This one DOES carry the
|
|
26
|
+
estate, in the body, and the repository upserts on it; it was simply never
|
|
27
|
+
checked.
|
|
28
|
+
|
|
29
|
+
For the six that name a record, the scope is now decided on the STORED row via
|
|
30
|
+
`siteReachOf` — the same rule `requireBulletinVideoReach` and
|
|
31
|
+
`requireEntryPassReach` already apply, and the same underlying `entitleSite`
|
|
32
|
+
verdict as `requireSiteReach`. `POST /settings` uses `requireSiteReach` on the
|
|
33
|
+
body's site, exactly as `GET /settings/:site` already does. No new mechanism and
|
|
34
|
+
no new permission string: reaching the estate is the whole rule, so the
|
|
35
|
+
contracted agency at the guardhouse, the org-wide manager and a resident reading
|
|
36
|
+
their own pass are all unaffected.
|
|
37
|
+
|
|
38
|
+
Where a write MOVES a card onto a unit (`card-replacement`, `assign-user`) the
|
|
39
|
+
destination unit is checked as well as the card, so a card cannot be re-parented
|
|
40
|
+
into an estate the caller cannot reach.
|
|
41
|
+
|
|
42
|
+
"Not yours" and "does not exist" answer identically (404), so card ids cannot be
|
|
43
|
+
enumerated. An id matching no row is refused rather than waved through, and a
|
|
44
|
+
row naming no estate fails closed. The guards sit after each handler's Joi
|
|
45
|
+
validation, so malformed input still answers 400 exactly as before.
|
|
46
|
+
|
|
47
|
+
`GET /door-access-levels`, `GET /lift-access-levels` and `GET /access-groups`
|
|
48
|
+
are deliberately NOT changed: they carry no estate and no record, only a
|
|
49
|
+
caller-supplied `acm_url`, so there is nothing to scope against without changing
|
|
50
|
+
their request contract. They are recorded as a separate piece of work.
|
|
51
|
+
|
|
52
|
+
- 042aae6: Scope invitation creation to an organisation the caller can reach, and stop a
|
|
53
|
+
non-staff caller minting a Seven365 staff invitation on the v1 endpoints.
|
|
54
|
+
|
|
55
|
+
Six endpoints took `metadata.org` — and the `app` that becomes the accepted
|
|
56
|
+
membership's `type` — straight from the request body, behind `requireAuth` and
|
|
57
|
+
nothing else:
|
|
58
|
+
|
|
59
|
+
- `POST /api/auth/invite` (`auth.route.ts:29`)
|
|
60
|
+
- `POST /api/auth/invite/user` (`auth.route.ts:30`)
|
|
61
|
+
- `POST /api/auth/invite/member` (`auth.route.ts:31`)
|
|
62
|
+
- `POST /api/auth/invite/service-provider/with-org` (`auth.route.ts:33`)
|
|
63
|
+
- `POST /api/auth/v2/invite` (`auth-v2.route.ts:22`)
|
|
64
|
+
- `POST /api/auth/v2/invite/organization` (`auth-v2.route.ts:23`)
|
|
65
|
+
|
|
66
|
+
Two things followed from that.
|
|
67
|
+
|
|
68
|
+
**The organisation.** The roles/members fix lets somebody holding an invitation
|
|
69
|
+
into an organisation reach it, because the client onboarding wizard needs that.
|
|
70
|
+
An attacker could therefore issue themselves the invitation the gate asks for —
|
|
71
|
+
one authenticated write naming any victim organisation — and walk straight back
|
|
72
|
+
into the chain that fix closed.
|
|
73
|
+
|
|
74
|
+
**`app`.** "admin" is what makes a membership Seven365 staff, and
|
|
75
|
+
`member.service.ts acceptInvite` writes `type: invite.metadata.app` verbatim.
|
|
76
|
+
`createMemberDirect` already refuses a staff membership from a non-staff caller;
|
|
77
|
+
the invitation road to the same place was open on the v1 mounts. The v2 twin
|
|
78
|
+
asked; the v1 mounts, which are the ones the web apps call, did not.
|
|
79
|
+
|
|
80
|
+
All six now call `requireInviteReach` (`utils/console-authz.util.ts`): Seven365
|
|
81
|
+
staff, a live member of the organisation, or somebody holding an invitation into
|
|
82
|
+
it — plus `requirePlatformStaff` when the invitation names the staff app. An
|
|
83
|
+
invitation that names no organisation is unchanged; it grants no organisation,
|
|
84
|
+
exactly as an org-less `GET /api/roles` does. The two service-provider invites
|
|
85
|
+
that already resolved their caller from the session are unchanged.
|
|
86
|
+
|
|
87
|
+
- dc7ab90: Scope the organisation list to the caller, and put the promo-code list behind the staff gate.
|
|
88
|
+
|
|
89
|
+
`GET /api/organizations` and `GET /api/promo-codes` performed no authorization
|
|
90
|
+
at all — `requireAuth` and nothing more — while every sibling handler in the
|
|
91
|
+
same two files guarded. So any signed-in account on the platform, a resident or
|
|
92
|
+
a cleaner included, could page through the name, e-mail, nature and status of
|
|
93
|
+
**every Seven365 client**, and read off **every discount code** we have ever
|
|
94
|
+
minted, with its rate and its expiry, and then redeem whichever is worth most.
|
|
95
|
+
|
|
96
|
+
The two rules are different, because the callers are different.
|
|
97
|
+
|
|
98
|
+
**`GET /api/organizations` is now a SCOPE, not a refusal.** Seven365 staff see
|
|
99
|
+
every organisation exactly as before; anybody else sees the organisations they
|
|
100
|
+
hold a live membership of, plus any registered under their own verified account
|
|
101
|
+
e-mail. The e-mail half is not decoration: `web-app-main
|
|
102
|
+
pages/verify/email/index.vue:322` — the org-owner self-signup — lists
|
|
103
|
+
organisations to find the one it created a line earlier, at which point that
|
|
104
|
+
brand-new client owner is a member of nothing. Refusing them would have traded
|
|
105
|
+
the leak for a broken signup. The comparison is the one
|
|
106
|
+
`getOrgsByEmail` already makes, against the address on the caller's own `users`
|
|
107
|
+
row; nothing the caller sends takes part in it. It also removes a real bug from
|
|
108
|
+
that flow: the page takes `items[0]` of a newest-first list, so two client
|
|
109
|
+
owners signing up in the same moment could be handed each other's organisation
|
|
110
|
+
as their `defaultOrg`.
|
|
111
|
+
|
|
112
|
+
The scope is part of the Redis cache key as well as the query, so one caller's
|
|
113
|
+
narrowed page is never replayed to the next, and `countDocuments` runs the
|
|
114
|
+
scoped query so pagination cannot walk past the scope.
|
|
115
|
+
|
|
116
|
+
**`GET /api/promo-codes` is Seven365 staff only,** together with
|
|
117
|
+
`GET /api/promo-codes/id/:id`, which is the same disclosure one record at a
|
|
118
|
+
time. Both are read solely by the staff console. A client at checkout is
|
|
119
|
+
unaffected: `web-app-org pages/checkout.vue` and
|
|
120
|
+
`subscription/manage-seats.vue` type a code and validate it through
|
|
121
|
+
`GET /api/promo-codes/code`, which stays signed-in-only and is untouched.
|
|
122
|
+
|
|
123
|
+
- c7b57f5: Make revoking somebody's access actually end their session.
|
|
124
|
+
|
|
125
|
+
A session in this product is an opaque `sid` whose only record of truth is a
|
|
126
|
+
Redis key `sid:<sid>` holding a copy of the user document. `requireAuth`
|
|
127
|
+
authenticates a request by finding that key and nothing else — it never reads
|
|
128
|
+
the database — and it re-`EXPIRE`s the key to four hours on every request, so a
|
|
129
|
+
session that is in use never ages out on its own.
|
|
130
|
+
|
|
131
|
+
**Signing out was never the problem.** `logout()` deletes the key, so a
|
|
132
|
+
signed-out session is refused; that half already worked. Every other way access
|
|
133
|
+
is taken away did not.
|
|
134
|
+
|
|
135
|
+
**Suspending somebody changed a column and nothing else.**
|
|
136
|
+
`suspendResidentById` writes `users.status = "suspended"` through
|
|
137
|
+
`updateUserFieldById`, which cleared the user-document cache and left the
|
|
138
|
+
`sid:` key untouched. The person carried on working, and every request they made
|
|
139
|
+
pushed the four-hour expiry out another four hours. On top of that,
|
|
140
|
+
`refreshToken()` minted a **brand new** session through `issueSession` with no
|
|
141
|
+
account-status check at all — the mobile refresh token is seven days and
|
|
142
|
+
rotating, so a suspended account's phone could keep issuing itself fresh
|
|
143
|
+
sessions indefinitely. Suspension was not a delay, it was a no-op for anyone
|
|
144
|
+
already signed in.
|
|
145
|
+
|
|
146
|
+
**Two of the statuses the product writes were never refused at sign-in either.**
|
|
147
|
+
Both `login()`s checked `suspended` and `pending` only, so `rejected` (written by
|
|
148
|
+
`reviewPerson`, whose email is the one that hides the login link) and `deleted`
|
|
149
|
+
(`UserStatus.DELETED`, the soft-delete marker) could sign in fresh with a
|
|
150
|
+
password.
|
|
151
|
+
|
|
152
|
+
The fix is the two halves of that, at the two choke points that already exist:
|
|
153
|
+
|
|
154
|
+
- **Minting refuses a non-live account** — `assertAccountLive` in `issueSession`
|
|
155
|
+
in both auth services, which is the one place every sign-in path in this
|
|
156
|
+
package goes through (login and mobile refresh, v1 and v2), for the same
|
|
157
|
+
reason `assertOrganizationNotSuspended` lives there.
|
|
158
|
+
- **Revoking destroys what already exists** — `revokeAccountAccess` from
|
|
159
|
+
`updateUserFieldById`, the only path in either user repository that writes
|
|
160
|
+
`users.status`. It deletes every `sid:` key the person holds and every refresh
|
|
161
|
+
token in `tokens`, so nothing can mint a replacement.
|
|
162
|
+
|
|
163
|
+
Finding every session a person holds needs an index: `users.sid` only ever
|
|
164
|
+
remembers the most recent sign-in, v2 never writes it, and nothing reads it — a
|
|
165
|
+
fix built on that column would revoke the phone and leave the browser signed in.
|
|
166
|
+
The index is a Redis set (`user-sessions:<userId>`), so there is no schema
|
|
167
|
+
change and no migration; `users.sid` stays as the fallback that catches v1
|
|
168
|
+
sessions minted before the set existed.
|
|
169
|
+
|
|
170
|
+
The blocking statuses are `suspended`, `pending`, `rejected` and `deleted`, as a
|
|
171
|
+
deny-list — `status` is optional on the model and legacy rows carry `""` or no
|
|
172
|
+
field at all, and an allow-list would lock every one of them out. `resubmit` is
|
|
173
|
+
deliberately live: that email keeps the login link, because resubmitting the
|
|
174
|
+
application is done signed in.
|
|
175
|
+
|
|
176
|
+
Nothing is logged out by deploying this — no existing session is touched, and
|
|
177
|
+
the two sign-in messages for `suspended` and `pending` are unchanged, because
|
|
178
|
+
`login()` still checks those two itself before minting. What changes for a
|
|
179
|
+
signed-in user is only that a `rejected` or soft-deleted account can no longer
|
|
180
|
+
obtain a session, and that a suspension now takes effect at once rather than
|
|
181
|
+
never.
|
|
182
|
+
|
|
183
|
+
- 6cbc3fa: Stop a deleted role from conferring Seven365 identity, and protect the seeded
|
|
184
|
+
platform-owner role.
|
|
185
|
+
|
|
186
|
+
Two defects on the roles collection, both of which made an ordinary
|
|
187
|
+
administrative action do the opposite of what it says.
|
|
188
|
+
|
|
189
|
+
**Deleting somebody's admin role did not take their access away.**
|
|
190
|
+
`deleteRole` (`role.repo.ts`) is a SOFT delete: it sets `{ status: "deleted" }`
|
|
191
|
+
and leaves the document in place. `isSuperAdmin` and `isPlatformOwner`
|
|
192
|
+
(`super-admin.util.ts`) read that role by `_id` alone, and so did the second
|
|
193
|
+
staff derivation in `resolveInviteActor` (`invite-actor.util.ts`). So the
|
|
194
|
+
ordinary way to revoke a Seven365 staff account — delete the admin role it sits
|
|
195
|
+
on — left the account platform staff: `requirePlatformStaff` still passed, and
|
|
196
|
+
staff identity is the bypass on `requireOrgAccess`, on `requireSiteReach` and on
|
|
197
|
+
organisation suspension. `getUserPermissions` (`permission.util.ts`) always
|
|
198
|
+
filtered on status, which is why the same account lost every permission and kept
|
|
199
|
+
the identity that makes permissions unnecessary.
|
|
200
|
+
|
|
201
|
+
All three identity derivations now require a live role. "Live" is spelled
|
|
202
|
+
`$ne: "deleted"` — the test already applied to the `members` row in the same
|
|
203
|
+
functions — rather than `status: "active"`. Roles carry exactly two statuses in
|
|
204
|
+
this package: `MRole` has defaulted `status` to `"active"` since the model's
|
|
205
|
+
first commit and every insert goes through `addRole` -> `new MRole()`, and
|
|
206
|
+
`"deleted"` is written by `deleteRole` alone (no API can send `status` on a
|
|
207
|
+
role; `updateRole` takes a name and permissions only). The two spellings
|
|
208
|
+
therefore agree on every row this package can write and differ only on a
|
|
209
|
+
pre-package row carrying no `status` at all, where `$ne` keeps that account
|
|
210
|
+
working and `"active"` would silently strip its console access. No migration is
|
|
211
|
+
implied.
|
|
212
|
+
|
|
213
|
+
**The seeded platform-owner role could be deleted, and nothing put it back.**
|
|
214
|
+
`createDefaultUser()` seeds one org-less `{ type: "admin", default: true }`
|
|
215
|
+
role, and that pair is the whole of `isPlatformOwner`. Deleting it is a one-way
|
|
216
|
+
door: `getRoles` lists active rows only, so it disappears from the roles screen,
|
|
217
|
+
and at the next boot `createDefaultUser` finds the deleted row by name and type
|
|
218
|
+
and reuses it rather than seeding a fresh one — leaving the owner account with
|
|
219
|
+
no permissions and no way to restore the role through the product. The Delete
|
|
220
|
+
and Edit buttons on the roles screen
|
|
221
|
+
(`layer-common components/RolePermissionMain.vue`) both reached it.
|
|
222
|
+
|
|
223
|
+
`role.repo.ts` now refuses to delete, rename or rewrite the permissions of that
|
|
224
|
+
role. The guard sits in the repository because all four write paths pass through
|
|
225
|
+
those three functions: `role.service.deleteRole`,
|
|
226
|
+
`role.service.deleteWithReassignments` (which deletes inside a transaction after
|
|
227
|
+
moving every member off), and the two `role.controller` edit paths that call the
|
|
228
|
+
repository directly — a guard in the service would have covered the deletes and
|
|
229
|
+
missed the edits. A CLIENT's own "owner" role is also `default: true`
|
|
230
|
+
(`subscription.service` writes two per new client) but is typed `organization`
|
|
231
|
+
or the org's nature, so requiring the platform staff type leaves every client's
|
|
232
|
+
own role editable exactly as before.
|
|
233
|
+
|
|
234
|
+
No new authorization mechanism is introduced, and no route's authentication or
|
|
235
|
+
permission requirements change.
|
|
236
|
+
|
|
237
|
+
- ebdfd23: Scope the role list, role creation and direct membership creation to an
|
|
238
|
+
organisation the caller can actually reach.
|
|
239
|
+
|
|
240
|
+
Two endpoints took the organisation straight from the request, and they chained
|
|
241
|
+
into a cross-tenant escalation that any signed-in account could run:
|
|
242
|
+
|
|
243
|
+
1. `GET /api/roles?org=<any org id>` listed that client's roles with their ids —
|
|
244
|
+
`role.controller.ts getRoles` made no scope call at all and `role.repo.ts`
|
|
245
|
+
put the caller's query string into `{ status: "active", org }`.
|
|
246
|
+
2. `POST /api/members/direct` wrote a membership into whatever `orgId` the body
|
|
247
|
+
named (`member.controller.ts createMemberDirect` validated the shape and
|
|
248
|
+
nothing else).
|
|
249
|
+
3. `member.model.ts` writes `status: value.status || "active"`, so that row is
|
|
250
|
+
live on insert.
|
|
251
|
+
4. `resolveInviteActor` then reports that organisation in the caller's `orgIds`,
|
|
252
|
+
which is exactly what `requireOrgAccess` asks for — the gate that every
|
|
253
|
+
role, member, people, site and camera fix in the 3.49.0 wave depends on.
|
|
254
|
+
|
|
255
|
+
So a resident, a visitor's contact or a cleaner could enrol itself as an active
|
|
256
|
+
member of any client organisation and inherit that client's reach. The
|
|
257
|
+
escalation to Seven365 platform owner was closed earlier; this is the same
|
|
258
|
+
attack scoped down one level, at the tenant.
|
|
259
|
+
|
|
260
|
+
The decision is a new `requireOrgReach` in `console-authz.util.ts`: **Seven365
|
|
261
|
+
staff, a live member of the organisation, or somebody holding an invitation into
|
|
262
|
+
it.** The third clause is not a convenience — three live flows legitimately
|
|
263
|
+
reach an organisation before any membership exists, and a flat membership test
|
|
264
|
+
would break all of them:
|
|
265
|
+
|
|
266
|
+
- the service provider signing in against a `service-provider-invite`, who looks
|
|
267
|
+
up the customer's "Admin Service Provider" role and creates it when the
|
|
268
|
+
customer has none (`web-app-main pages/sign-in.vue:298,311,428`);
|
|
269
|
+
- the admin member finishing sign-up from a `member-invite`
|
|
270
|
+
(`web-app-main pages/verify/email/index.vue:462`);
|
|
271
|
+
- the client owner walking the onboarding wizard
|
|
272
|
+
(`web-app-org pages/onboarding/getting-started/index.vue`).
|
|
273
|
+
|
|
274
|
+
All three hold a verification addressed to their own email whose `metadata.org`
|
|
275
|
+
is the organisation being reached, and that invitation is the relationship the
|
|
276
|
+
rule tests (`hasOrgInvitation` in `invite-actor.util.ts`). The address is read
|
|
277
|
+
from the `users` row for the session id, never from the request; both the
|
|
278
|
+
ObjectId and the older string shape of `metadata.org` are matched, the same pair
|
|
279
|
+
`completePendingInvites` matches on; and only the three invitation types count,
|
|
280
|
+
so a password-reset code opens nothing.
|
|
281
|
+
|
|
282
|
+
Where the check now runs:
|
|
283
|
+
|
|
284
|
+
- `GET /api/roles` — when the caller sends an `org`. With no `org` the query is
|
|
285
|
+
unchanged and still matches only the ORG-LESS platform roles, which is what
|
|
286
|
+
every invitation screen asks for and is not a tenant's data.
|
|
287
|
+
- `POST /api/roles` and `POST /api/roles/v2` — on the `org` the new role is
|
|
288
|
+
filed under. Both mounts, because `/v2` is a separate controller and gating
|
|
289
|
+
one would leave the hole reachable by adding `/v2` to the URL.
|
|
290
|
+
- `POST /api/members/direct` — enrolling YOURSELF needs reach, so an invitation
|
|
291
|
+
counts; enrolling ANYBODY ELSE needs `requireOrgAccess`, real membership. The
|
|
292
|
+
onboarding wizard's second call, which adds the Seven365 support account,
|
|
293
|
+
always follows the caller's own row, so it passes.
|
|
294
|
+
|
|
295
|
+
`requireOrgAccess` itself is untouched. The invitation clause is additive and
|
|
296
|
+
lives in a separate function — widening the shared one would hand all 78 of its
|
|
297
|
+
mounts to anybody holding an invitation.
|
|
298
|
+
|
|
299
|
+
Proved end to end in `test/e2e/org-scoping-roles-members.e2e.test.mjs`: 13 of
|
|
300
|
+
its 23 cases fail on the code before this change and all 23 pass after, while
|
|
301
|
+
every "must keep working" case — the caller's own org, the org-less platform
|
|
302
|
+
list, a member enrolling a colleague, staff reaching any client, and the whole
|
|
303
|
+
invited-newcomer flow — passes on both.
|
|
304
|
+
|
|
305
|
+
### Patch Changes
|
|
306
|
+
|
|
307
|
+
- 7bf8130: Decide the HRM Labs location server-side, so an entitled caller cannot read
|
|
308
|
+
another site's roster.
|
|
309
|
+
|
|
310
|
+
`requireSiteReach(req, siteId, ATTENDANCE_READ)` guards all four manpower-attendance
|
|
311
|
+
reads, but `siteId` is not what selects the roster — it only picks the local
|
|
312
|
+
`manpower-settings` row. The roster is selected by `locationName`, the field HRM Labs
|
|
313
|
+
is asked for, and that came straight from the request body's `siteName` with nothing
|
|
314
|
+
checking it against `siteId`.
|
|
315
|
+
|
|
316
|
+
So the two could disagree. A caller entitled to site A could send site A's `siteId`,
|
|
317
|
+
pass the guard, and pair it with **another** site's `siteName` — and read that site's
|
|
318
|
+
employee roster back: names, staff numbers, job titles, NRICs
|
|
319
|
+
(`identificationNumber`) and the check-in/check-out photographs. The exposure had been
|
|
320
|
+
narrowed from anyone on the internet to any signed-in account; this closes it.
|
|
321
|
+
|
|
322
|
+
The location now comes from `config.siteName`, the settings row the caller was already
|
|
323
|
+
entitled to, via one `locationOf()` helper called in each of the four functions. That
|
|
324
|
+
is the same source the screen reads from: the security app fills `siteId` and
|
|
325
|
+
`siteName` from one HRM Labs site-list entry (`site.id` / `site.text`),
|
|
326
|
+
`manpowerMonitoringSchema` makes `siteName` required on both write paths, and
|
|
327
|
+
`TManpowerMonitoringUpdate` cannot change it — so for every legitimate row the pair
|
|
328
|
+
already agreed and the server simply stops asking.
|
|
329
|
+
|
|
330
|
+
A row with no stored name is refused rather than falling back to the body; a fallback
|
|
331
|
+
would re-open the hole for exactly the rows an attacker would want to name for
|
|
332
|
+
themselves.
|
|
333
|
+
|
|
334
|
+
Nothing legitimate changes. The Joi schemas still accept `siteName`, so no client needs
|
|
335
|
+
a coordinated release — it is accepted and ignored. The three internal callers
|
|
336
|
+
(`manpower.event.ts` and the two `cron.util.ts` sweeps) get more reliable, not less:
|
|
337
|
+
they were passing `manpower-remarks.siteName`, which that model marks optional.
|
|
338
|
+
|
|
339
|
+
- 0be47d5: Scope the "my memberships" reads and the preloved marketplace to the record's stored owner.
|
|
340
|
+
|
|
3
341
|
## 3.49.0
|
|
4
342
|
|
|
5
343
|
### Minor Changes
|
package/dist/index.js
CHANGED
|
@@ -19390,10 +19390,28 @@ var schemaUpdateSiteCamera = import_joi20.default.object({
|
|
|
19390
19390
|
then: import_joi20.default.required(),
|
|
19391
19391
|
otherwise: import_joi20.default.optional().allow(null, "")
|
|
19392
19392
|
}),
|
|
19393
|
-
|
|
19393
|
+
/**
|
|
19394
|
+
* `.valid()`, NOT `.allow()` - the same defect already fixed on `type` in
|
|
19395
|
+
* `schemaSiteCamera` below, on the two
|
|
19396
|
+
* remaining fields where the stored value is safe to constrain.
|
|
19397
|
+
*
|
|
19398
|
+
* `.allow()` whitelists ADDITIONAL values on a validator that already accepts
|
|
19399
|
+
* every string, so these three lines have never rejected anything. Measured
|
|
19400
|
+
* on the staging `site.cameras` collection (22 rows, 2026-08-26): `category`
|
|
19401
|
+
* is only ever standard|resident|visitor and `status` only ever
|
|
19402
|
+
* active|inactive, with no nulls, blanks or missing fields - and the only two
|
|
19403
|
+
* write callers in the estate (layer-common `CameraForm.vue` and
|
|
19404
|
+
* `iservice365-mobile-app-security`) offer exactly those values.
|
|
19405
|
+
*
|
|
19406
|
+
* `direction` is deliberately left on `.allow()`: `CameraForm.vue` also
|
|
19407
|
+
* offers "visitors" and "residents", which are outside the intended
|
|
19408
|
+
* entry|exit|both|none set, so constraining it would 400 two of the form's
|
|
19409
|
+
* own five options. That mismatch needs a product decision, not a schema edit.
|
|
19410
|
+
*/
|
|
19411
|
+
category: import_joi20.default.string().valid("standard", "resident", "visitor").required(),
|
|
19394
19412
|
guardPost: import_joi20.default.number().integer().optional(),
|
|
19395
19413
|
direction: import_joi20.default.string().optional().allow("entry", "exit", "both", "none"),
|
|
19396
|
-
status: import_joi20.default.string().optional().
|
|
19414
|
+
status: import_joi20.default.string().optional().valid("active", "inactive", "deleted"),
|
|
19397
19415
|
name: import_joi20.default.string().optional().allow(null, "")
|
|
19398
19416
|
});
|
|
19399
19417
|
var schemaSiteCamera = import_joi20.default.object({
|
|
@@ -19426,10 +19444,11 @@ var schemaSiteCamera = import_joi20.default.object({
|
|
|
19426
19444
|
* today. Every client in the estate already sends the lower-case pair.
|
|
19427
19445
|
*/
|
|
19428
19446
|
type: import_joi20.default.string().trim().lowercase().valid("ip", "anpr").required(),
|
|
19429
|
-
|
|
19447
|
+
/** See the note on `schemaUpdateSiteCamera` for why `direction` stays as it is. */
|
|
19448
|
+
category: import_joi20.default.string().valid("standard", "resident", "visitor").required(),
|
|
19430
19449
|
direction: import_joi20.default.string().required().allow("entry", "exit", "both", "none"),
|
|
19431
19450
|
guardPost: import_joi20.default.number().integer().optional(),
|
|
19432
|
-
status: import_joi20.default.string().optional().
|
|
19451
|
+
status: import_joi20.default.string().optional().valid("active", "inactive", "deleted"),
|
|
19433
19452
|
name: import_joi20.default.string().optional().allow(null, ""),
|
|
19434
19453
|
createdAt: import_joi20.default.date().optional(),
|
|
19435
19454
|
updatedAt: import_joi20.default.date().optional(),
|