Pinbox24 W4 — Profile → Office Invitation → Workspace Assignment (Provisioning Flow)
Applies to: W4 (v42-prod, current production, api.w4.pinbox24.com) backend p24-back-ts
(gitlab.com/pinbox24/p24-back-ts, development branch). Traced from source, not the deployed
binary — see “Known discrepancy vs. live production” below before trusting this over live testing.
Origin: issue #3826 (the pinbox24_com_power_user service account was never properly
provisioned — zero real records in office.users/pinbox.profiles, unlike a working account such
as radieu@gmail.com which has ~100 office.users entries, one per office). This playbook
documents the actual application-level provisioning mechanism so a future session can either drive
it via API calls or understand exactly what a manual DB write needs to reproduce.
Source snapshot: cloned read-only 2026-08-05 via the pattern in
docs/pinbox24/gitlab-repo-locations.md (GITLAB_ADMIN_PAT, secrets/administration.env.sops).
All file:line citations below are relative to the repo root and were verified by direct reading, not
inferred.
Collection map (one MongoDB database, dotted collection names)
There is a single Mongoose connection (src/config/mongoDB.ts:14-36) — pinbox. / office. are
literal dots inside one collection name, not separate databases. Model → collection mapping
(src/constants/dbCollection.const.ts):
| Model constant | Model name string | Actual Mongo collection (Mongoose auto-pluralized) |
|---|---|---|
PROFILE_COLLECTION | "pinbox.profile" | pinbox.profiles |
OFFICE_USERS_COLLECTION | "office.user" | office.users |
USER_INVITATION_COLLECTION | "userInvitation" | userInvitations |
OFFICE_COLLECTION | "office" | offices |
REGISTER_COLLECTION | "registrie" | registries — workspaces are IRegister docs with type:"workspace" |
PROFILE_EMAIL_COLLECTION | "pinbox.emails" | pinbox.emails |
credentials.json at the repo root is a GCP service-account key, unrelated to user credentials —
noted only so nobody mistakes it for something relevant here.
Layer 1 — Platform profile creation (pinbox.profiles)
Self-service endpoints (public, no auth — src/middleware/authGuard.middleware.ts:16-24)
| Endpoint | Method | File:line |
|---|---|---|
/api/registration | POST | src/apps/auth/auth.route.ts:47-60 → registerToPlatform, auth.controller.ts:94-143 |
/api/signup | POST | auth.route.ts:61- → signupToPlatform, auth.controller.ts:145- — sends a 6-digit OTP via Redis (5 min TTL, auth.controller.ts:151-152) |
/api/signup-verify-otp | POST | → signupOtpToPlatform, auth.controller.ts:192-198 creates the profile identically to registerToPlatform once the OTP is verified |
Document written to pinbox.profiles on creation (auth.controller.ts:96-102):
{
gravatarEmail: req.body.email,
login: req.body.email, // unique key — this IS the "email" field, pinboxProfile.model.ts:18-21 unique:true
sourceDomain: req.headers["host"] || "w4.pinbox24.com",
offices: [],
pass: req.body.password // stored AS-IS at signup — see password section below
}registerToPlatform also builds (but never executes — the actual createNewOfficeHelperV2 call is
commented out, auth.controller.ts:136-137) a type:"privateArea" personal-office payload. This
is dead code in the current source: self-registration does not currently create a personal
office, despite the request payload being assembled for it.
If the request Host header matches an OfficeDomains doc with officeType:"single" and a
selectedOfficeId (a white-label single-tenant domain), the new profile is auto-added to that one
office via addUserToOffice() (auth.controller.ts:126-134, see Layer 1 admin path below). This is
a narrow domain-based special case, not the general flow.
Password / credential storage — inconsistent across code paths, treat all as sensitive
Earlier investigation (#3826) claimed auth.helper.ts hashes with crypto.createHash("md5"). That
is wrong for auth.helper.ts itself — login there is a plaintext string compare:
// src/apps/auth/auth.helper.ts:51
assert.equal(profileInfo.pass, password, "Username or Password Incorrect");MD5 hashing does exist, but only in two other, narrower code paths:
src/apps/auth/pinboxProfiles/pinboxProfile.controller.ts:422-424— self-service password change (PATCH /api/profile/:profileId):crypto.createHash("md5").update(updatedData.password).digest("hex").src/apps/auth/pinboxProfiles/pinboxProfile.helper.ts:270-280(defaultProfileDoc) — used only when the system generates an account for someone else (random password, MD5-hashed, mailed to them) — invoked fromcheckProfileAndCreate(src/globalHelpers/notifications/webhook.helper.ts:179-235) andresetPasswordFromTask(webhook.helper.ts:164-177).
Net effect: passwords set by the user themselves (registration, OTP signup, invitation
set-password, forgot-password reset) land in pinbox.profiles.pass as plaintext
(auth.controller.ts:101,197,336; src/apps/userInvitation/setPasswordInvitation.helper.ts:14-17).
Since login() does a raw equality check, an MD5-hashed value only “works” if the client re-sends
the same MD5 hex string as the password at login — hashing is a client-side convention for those two
code paths, not a server-enforced scheme. Treat pinbox.profiles.pass as a live credential field
regardless of which encoding a given row happens to be in — never read, log, or hand-edit it.
A dead constant NEW_PROFILE_DEFAULT_PASSWORD (MD5 of Pinbox24.com@123,
src/constants/common.const.ts:144-145) is not referenced anywhere in src/ — a leftover, not a
live default.
Admin-initiated profile creation (the path relevant to a service account)
There is no endpoint that creates a pinbox.profiles doc for a third party under a superAdmin
gate — src/apps/superAdmin/ only lets a hardcoded super admin self-join an existing office
(GET /api/super-admin/join-office/:officeId, superAdmin.controller.ts:47-73, gated by
src/middleware/superAdminGuard.middleware.ts:5-8 — hardcoded allowlist
["suryanshsinghstudy@gmail.com","radieu@gmail.com"], not a DB role flag). It operates on the
caller’s own already-existing profile; it does not create one.
The actual admin-initiated third-party path is POST /api/offices/invitation/send
(inviteToOffice, src/apps/offices/office.route.ts:34-39 → office.controller.ts:250-269).
Requires only a valid JWT for any existing member of the target office (route type
"officeAuth", no isAdmin check in the handler). Body:
{ "officeId": "<hex>", "selectedUser": [{ "login": "email@example.com", "shortName": "...", "longName": "...", "phoneNo": "..." }] }For each selectedUser not already a profile, it calls checkProfileAndCreate()
(webhook.helper.ts:179-235) — creates the pinbox.profiles doc via defaultProfileDoc()
(MD5-hashed random password, mailed to the new user) — then calls addUserToOffice()
(office.helper.ts:368-411) which immediately creates the office.users doc, no invitation
record, no accept step, no email confirmation loop:
// office.helper.ts:387-401 (verified — the officeDoc.users.push(...) block above it, lines 372-386, is commented out)
await OfficeUsersModel.create({
email: userDoc.login,
profileId: userDoc.id,
officeId: officeDoc._id,
shortName: user.shortName,
longName: user.longName,
uid: `${userDoc.login}_${officeDoc._id}`,
phoneNo: user.phoneNo,
groups: [{ "name": "Standard Access", "description": "Standard Access for user" }]
})and pushes a bare { officeId } (no workspaces) into pinbox.profiles.offices[]
(office.helper.ts:404-410). This path grants office-level access only — it does not assign any
workspace. Workspace assignment still needs Layer 2/3 below (an invitation scoped to a workspace,
run through accept — updateOfficeDoc no-ops if isOfficeUser is already true, so re-running the
invitation flow for the same office is safe and only adds the workspace).
POST /api/offices/new (createNewOffice, office.route.ts:156-160 → office.controller.ts:384-414
→ createNewOfficeHelperV2, src/apps/createNewOffice/createNewBasicOffice.helper.ts:36-49) lets
any authenticated user create a brand-new office and become its isAdmin:true owner
(ele.isAdmin = true, line 40) — this is the “become an office owner” path, not third-party
provisioning, but is the way to create a new office to provision the service account into if one
doesn’t already exist.
Layer 2 — Office invitation (userInvitations → office.users)
There is a README at src/apps/userInvitation/README.md giving the intended design (request →
create invitation → create profile if needed → accept/reject → 14-day expiry, revocable) — the
implementation below matches it, with field names filled in from the actual model/interface.
Create-invitation endpoint
POST /api/offices/invitation (src/apps/userInvitation/userInvitation.route.ts:6-13 → inviteUser,
userInvitation.controller.ts:18-28). Auth: valid JWT plus an officeId header (asserted at
userInvitation.controller.ts:20) — the caller must already be a member of that office
(officeGuardMiddleWare → checkAuthUserOfOffice, see Layer 2 authz note below). No isAdmin
check anywhere in inviteUserHelper — any office member can invite, not just office admins.
Request body (userInvitation.helper.ts:21-46):
{
"invitedToEmails": ["email1@example.com", "email2@example.com"],
"workspaceSlug": "administracja",
"message": "optional note",
"invitedUserRole": "admin | standard | light"
}officeId comes from the header, not the body. One invitation document is created per email in
invitedToEmails, and the invitation is scoped to exactly one workspace (by slug) per call —
there is no multi-workspace array on create.
Fields written to userInvitations at creation
Schema (userInvitation.model.ts:10-82) / interface (userInvitation.interface.ts:3-22):
invitedByEmail, invitedById, invitedToEmail (lowercased), oldInvitedEmail, workspaceSlug,
invitedToId (filled in later if the profile exists), officeId, workspaceId, invitationToken
(schema field present but no code path populates or validates it — the real “token” in practice
is the invitation’s own Mongo _id used in the email link), status (enum
sended|pending|accepted|canceled|rejected|accountCreated|expired, default sended),
expirationDate (moment().add(14,"d"), from USER_INVITATION_DEFAULT_EXPIRATION_TIME,
common.const.ts:139-142), createdDate, isOfficeUser/isWorkspaceUser/isNewUser (booleans,
computed by updateAllInvitedRecord, userInvitation.helper.ts:113-150, README’s
isUserOfOffice/isUserOfWorkspace/newUser under slightly different field names),
invitationType (office|workspace, default office), invitedUserRole (admin|standard|light,
default light), deleted. updateAllInvitedRecord also sends the invitation email
(Mailgun template sendInvitation/pl) and sets status:"pending".
Accept flow — acceptUserInvitationHelper (acceptUserInvitation.helper.ts:21-56)
-
Looks up the invitation by id with
status in [sended,pending,accountCreated](:23-25), re-resolvesinvitedToIdby looking up the profile frominvitedToEmail(:29— not from the caller’s JWT), checks expiry (checkInvitationExpiration,:31,70-80— amoment()comparison at read time, not a TTL index and not cron-enforced, see expiry section below). -
Resolves
workspaceIdby slug if not already set (:34-38). -
Runs three writes in parallel (
:40-44):a)
updateOfficeDoc(:99-119) — no-op ifisOfficeUseralready true, else creates theoffice.usersdocument:await OfficeUsersModel.create({ active: true, email: userInvitationDoc.invitedToEmail, profileId: profileDoc._id.toString(), officeId: officeDoc._id, uid: `${profileDoc._id}_${officeDoc._id}`, isAdmin: false, groups: OFFICE_USER_INIT_GROUP, // [{name:"Standard Access", description:"Standard Access for user"}] managegroups: [] });Then sets
isOfficeUser: falseon the invitation (:115) — this looks like a bug (should plausibly betrue); harmless for provisioning since nothing re-reads that flag afterward, but don’t treat it as a reliable “did this succeed” signal.b)
updateWorkspaceDoc(:121-140) — pushes{ role: invitedUserRole, userEmail, userId }into the workspaceRegisterModel.users[]array (dedup byuserEmail), setsisWorkspaceUser: true.c)
addToUserProfile(:142-179) — the only placepinbox.profiles.offices[].workspaces[]gets written. Pushes{ officeId, workspaces: [{icon,name,workspaceId,slug}] }if the office isn’t in the array yet, else appends to the existing office entry’sworkspaces[]if that specific workspace isn’t there yet. -
Sends acceptance emails + a Discord notification, sets invitation
status:"accepted"(:51).
Note the uid format is inconsistent across code paths — acceptUserInvitation.helper.ts:110
builds ${profileDoc._id}_${officeDoc._id} (profileId, underscore), while office.helper.ts:393
(the admin-bypass path) builds ${userDoc.login}_${officeDoc._id} (login/email, underscore), and
createNewBasicOffice.helper.ts:42 uses a hyphen with email. uid has a schema-level
unique: true (officeUsers.model.ts:35-38) but no canonical construction rule — never hand-build
this field by guessing the format; read a real existing doc in the target office first if you must
construct one manually.
Authorization gap worth knowing about, not fixing here: acceptUserInvitationHelper never
checks that the caller’s JWT identity matches invitedToEmail — the token is only used for the
inviter-notification email context (:47,83-89). Any authenticated user who knows/guesses a
userInvitationId can trigger acceptance for someone else’s invitation. Same absence of an ownership
check applies to reject/cancel/delete (userInvitation.controller.ts:178-215).
Reject flow (rejectUserInvitation.helper.ts:12-41)
Looks up the invitation (status in [pending, accountCreated]), sets status:"rejected", sends
notification emails. Does not touch office.users or pinbox.profiles — purely a status flip.
Expiration handling — not a TTL index
No expires: option on the schema’s expirationDate. Expiry is:
- Checked on accept (
checkInvitationExpiration, throws if past due). - Computed on read (
isExpiredinuserInvitation.controller.ts:66,123;pinboxProfile.helper.ts:239-244for a dashboard count). - A cron-triggered helper
invitationExpireCron(userInvitation.helper.ts:220-250, wired atsrc/apps/cronScript/cronScript.route.ts:38) sends reminder/expired emails only (day-before, day-after) — it never writesstatus:"expired"or deletes anything. “Expired” is a purely virtual state, recomputed everywhere fromexpirationDate. - Manual revocation before expiry:
GET /api/offices/user-invitation/:userInvitationId/cancel→status:"canceled"(userInvitation.controller.ts:193-200).
All invitation endpoints
| Endpoint | Method | Auth | File:line |
|---|---|---|---|
/api/offices/invitation | POST | JWT + officeId header, office member | userInvitation.route.ts:8-13 |
/api/offices/user-invitation-un-auth/:userInvitationId | GET | none | userInvitation.route.ts:41-51 |
/api/offices/user-invitation-un-auth/:userInvitationId/set-password | POST | none | userInvitation.route.ts:53-69 |
/api/offices/user-invitation/:invitationId (re-invite) | GET | JWT | userInvitation.route.ts:29-39 |
/api/offices/user-invitation/:userInvitationId/migrate-invitation | POST | JWT | userInvitation.route.ts:71-88 |
/api/offices/user-invitation/:userInvitationId/reject | GET | JWT | userInvitation.route.ts:90-100 |
/api/offices/user-invitation/:userInvitationId/cancel | GET | JWT | userInvitation.route.ts:102-112 |
/api/offices/user-invitation/:userInvitationId/accept | GET | JWT | userInvitation.route.ts:114-124 |
/api/offices/user-invitation/:userInvitationId/delete | DELETE | JWT | userInvitation.route.ts:126-136 |
/api/offices/user-invitation/list, /count | GET | JWT | userInvitation.route.ts:15-27 |
officeGuardMiddleWare exempts the entire /api/offices/user-invitation/ prefix (both un-auth and
auth variants) from the officeId-header requirement — only authGuardMiddleWare’s narrower exempt
list (-un-auth- paths only, authGuard.middleware.ts:28) determines which need a Bearer JWT at
all.
New-user practical sequence: GET .../user-invitation-un-auth/:id (fetch invite details, no
auth) → POST .../set-password (creates the pinbox.profiles doc, plaintext password, and returns
a JWT via login() — setPasswordInvitation.helper.ts:7-24) → the frontend then calls the
JWT-protected GET .../accept/:id with that fresh token. Existing-user sequence: email link
routes to /login?redirectUrl=/invitation/list (userInvitation.helper.ts:172-173), accept happens
post-login.
Layer 3 — Workspace assignment within an office
Workspace assignment happens inside the same invitation/accept call, not as a separate step.
Every userInvitations document is scoped to exactly one workspaceSlug/workspaceId from
creation (inviteUserHelper, userInvitation.helper.ts:28,33), and acceptUserInvitationHelper’s
updateWorkspaceDoc + addToUserProfile write the workspace membership atomically alongside the
office membership, in the same accept call (updateOfficeDoc simply no-ops if office access already
exists).
Checked for a standalone post-hoc “assign to an additional workspace” endpoint — none works:
POST /api/offices/work-space/:slug/users/new(createNewWorkspaceUser,src/apps/workspace/workspace-users/workspace-users.controller.ts:98-105) is wired into routing and Joi-validated but the handler body is a commented-out stub — it does nothing despite looking live. Do not rely on it.PUT /api/offices/work-space/:slug/users/:userId(updateWorkspaceUserDoc, same file:115-132) only changes theroleof an already-existing workspace member; it cannot add a new one.DELETE /api/offices/work-space/:slug/users/:userIdremoves a workspace member — works, but is removal, not assignment.src/apps/officeUsers/officeUsers.controller.tsis an empty file;officeUsers.model.ts/officeUsers.helper.tsexpose onlygetOfficeUserById(Redis-cached lookup, used internally byofficeGuardMiddleWareandoffice.helper.ts) — no routes are registered for this module at all (not imported intoapp.routing.ts).src/apps/workspaceTemplate/is workspace-template CRUD (reusable configs used at office-creation time) — unrelated to per-user membership.
Conclusion: there is no working standalone “add this office member to another workspace” API.
The only functioning mechanism is creating a new userInvitations record scoped to the target
workspace and running it through accept — even for a user who already has office access
(updateOfficeDoc no-ops on the office write and only performs the workspace write in that case).
Known discrepancy vs. live production — verify empirically, don’t trust source alone
docs/playbooks/pinbox24-office-membership-grant.md (live-tested against production, 2026-08-02/03)
found that the actual W4 access gate for GET /api/offices was the embedded users[] array on
the offices collection document itself, and that writing pinbox.profiles.offices[] alone did
not move GET /api/offices. Tracing the current development branch source directly
contradicts part of that:
getOfficeListHelper(src/apps/offices/office.helper.ts:91-128, backsGET /api/offices) reads onlypinbox.profiles.offices[]to get the list of office IDs, then fetches thoseOfficeModeldocs by_id— it does not read or filter on anyoffices.users[]field at all.- The write to the embedded
offices.users[]array is present in source but commented out (office.helper.ts:372-386, insideaddUserToOffice) — dead code, currently never executed by any path found in this investigation (invitation-accept, admin bypass, or self-registration). - The per-request office-membership gate used by
officeGuardMiddleWareforofficeId-scoped calls (checkAuthUserOfOffice,src/middleware/officeGaurd.middleware.ts:252-264) queries the standaloneoffice.userscollection (getOfficeUserById,officeUsers.helper.ts:7-26, Redis-cached under keyoUser:${officeId}:${profileId}) — consistent with what both this investigation and the office-membership-grant playbook found for that specific check.
Plausible explanations for the mismatch, none confirmed: the deployed W4 production binary may lag
the development branch (known risk pattern elsewhere in this stack, see the
“persistent-patches gotcha” in docs/w3-w4-stack-operations.md); the office-membership-grant
investigation’s test office may have been filtered out of getOfficeListHelper’s mongoFilter for
an unrelated reason (deleted:true or type:"privateArea", office.helper.ts:108-109) rather than
by a users[] check; or there is Redis caching elsewhere in the profile-read path not visible in a
static source read. Do not assume either document alone is authoritative — after provisioning via
the invitation flow, always do the live GET /api/offices verification from
pinbox24-office-membership-grant.md step 5, and if the office doesn’t show up, fall back to that
playbook’s direct offices.users[] MongoDB write as a supplementary step, even though this
investigation found nothing in current source that reads that field.
Step-by-step: provisioning a new service account end-to-end (e.g. pinbox24_com_power_user)
This is a procedure to follow, not something this session executed — #3826 asked for documentation only.
-
Decide the target office(s) and workspace(s) the service account needs. Get the
officeId(ObjectId) and the workspaceslugfor each. If no suitable office exists yet,POST /api/offices/newfirst, using a real logged-in admin’s JWT (the caller becomes that office’sisAdmin). -
Create the profile. Two options:
- Preferred — via invitation (see step 3;
setPasswordInvitation.helper.tscreates the profile automatically as part of accept for a brand-new email, no separate call needed). - Direct registration —
POST /api/registrationwith{email, password}using the service account’s intended login email. Scriptable, no auth required. Createspinbox.profileswith a plaintextpass— pick a strong password and store it immediately in the correct SOPS file (this is now an application credential, not infra — follow the standard secret-manager flow rather than leaving it only in the request you made).
- Preferred — via invitation (see step 3;
-
Invite the profile to the office + workspace, as an existing office member (any member, not just admin):
POST /api/offices/invitationwithofficeIdheader set, body{"invitedToEmails": ["pinbox24_com_power_user@..."], "workspaceSlug": "<target-workspace-slug>", "invitedUserRole": "standard"}. This is scriptable end-to-end with just an HTTP client and a bearer token belonging to any real office member. -
Accept the invitation. This is the one step that is not cleanly scriptable for a service account without either (a) knowing the invitation’s Mongo
_id(retrievable viaGET /api/offices/user-invitation/listwith the inviter’s own JWT — it lists invitations that inviter sent) and calling the JWT-gatedGET /api/offices/user-invitation/:userInvitationId/acceptusing a JWT for the invited account itself (log in as the service account first viaPOST /api/authonce step 2/3 gave it a password), or (b) if the profile didn’t exist before the invitation, calling the un-authPOST /api/offices/user-invitation-un-auth/:userInvitationId/set-passwordwith a chosen password — this both creates the profile and returns a JWT you can immediately use for the accept call in the same script. Given the authz gap noted above (accept doesn’t check caller identity againstinvitedToEmail), technically any valid JWT can trigger the accept call, but do not rely on that — it’s an unintended gap, not a documented feature, and could be closed without notice. -
Repeat step 3–4 per additional workspace if the service account needs more than one workspace in the same office (each invitation is single-workspace-scoped).
-
Verify. Log in as the service account (
POST /api/auth, password sent as MD5 hex of the plaintext per the client-side convention documented indocs/playbooks/pinbox24-office-membership-grant.mdstep 5 — confirm empirically which encoding applies to the password you actually set, since Layer 1 above shows registration/invitation paths store plaintext while a couple of other paths MD5-hash), thenGET /api/officesshould list the granted office. If it does not, see the “Known discrepancy” section above before assuming the invitation flow failed — check the office doc’s embeddedusers[]array too. -
Never hand-insert
pinbox.profilesoroffice.usersdocuments directly in MongoDB as a substitute for steps 2–4 unless every field this playbook documents (correctpassencoding, correctuidformat sampled from a real doc in the target office,groups/isAdmindefaults, thepinbox.profiles.offices[].workspaces[]entry) is reproduced by hand — partial hand-inserts are exactly howpinbox24_com_power_userended up broken in the first place (#3826). If a manual DB write is unavoidable (e.g. because of the discrepancy in the previous section), followdocs/playbooks/pinbox24-office-membership-grant.md’s step-by-step instead of improvising a new shape.
What’s scriptable vs. human/UI-only
Scriptable with just an HTTP client + bearer token:
POST /api/registration,POST /api/signup+/api/signup-verify-otp— profile creationPOST /api/offices/new— office creationPOST /api/offices/invitation/send(inviteToOffice) — direct office add, no invitation/email step, no admin gate (any office member’s token); does not assign a workspacePOST /api/offices/invitation(inviteUser) — create invitation(s) + trigger emailGET /api/offices/user-invitation/:id/accept|reject|cancel,DELETE .../delete— any valid JWT works today due to the missing ownership check (do not depend on this — see authz gap note)GET /api/super-admin/join-office/:officeId— scriptable but restricted to 2 hardcoded emailsPUT /api/offices/:officeId/users/:email(updateOfficeSingleUser,office.controller.tsaround line 1211) — direct upsert ofoffice.usersfields (groups, isAdmin) for an existing office member, scriptable, requires office membershipDELETE /api/offices/:officeId/users/:email— scriptable office removal
Human/UI-only (or at least not meaningfully scriptable without a human step in the loop):
GET /api/offices/user-invitation-un-auth/:id+POST .../set-password— technically plain unauthenticated HTTP calls, but the invitation_idis only discoverable via the emailed link unless you already have inviter-side visibility (.../user-invitation/list)POST /api/offices/user-invitation/:id/migrate-invitation— needs a destination account’s raw email+password, designed as a UI form action- Anything behind
superAdminGuardMiddleWare— hardcoded to 2 personal Gmail addresses, not a DB role, so it’s tied to specific human accounts, not automatable generally POST /api/offices/work-space/:slug/users/new— dead stub, not usable by anyone
MongoDB fields — never hand-edit vs. safe to read
Never hand-edit (credential / access-control / uniqueness-constrained):
pinbox.profiles.pass— password, plaintext in most paths, MD5 in a couple (see Layer 1) — never read, log, or export.pinbox.profiles.resetPasswordToken/resetPasswordExpires(pinboxProfile.model.ts:84-89) — live password-reset state.office.users.uid—unique:true(officeUsers.model.ts:35-38) and built with inconsistent formats across code paths (see the uid note in Layer 2) — never hand-construct; read a real sibling doc in the same office first.office.users.isAdmin/groups/managegroups— authorization fields checked byofficeGuardMiddleWareand downstream business logic; treat as privileged, not casual data.userInvitations.invitationToken— schema field, but nothing populates or validates it currently; don’t fabricate a value expecting it to do anything.credentials.json(repo root) — GCP service-account private key, a secret file.
Safe to read for diagnostics:
pinbox.profiles.offices[].workspaces[].{icon,name,slug}— display metadata.office.users.{shortName,longName,phoneNo,color,toolbarShortcut}— display data.userInvitations.{status,expirationDate,isOfficeUser,isWorkspaceUser,isNewUser,createdDate, workspaceSlug,officeId,invitedByEmail,invitedToEmail}— safe for audit/diagnostics;statuscan be manually corrected in an emergency (e.g. a stuck"pending"row) since accept/reject re-derive everything else from live office/workspace/profile lookups rather than trusting cached invitation fields, aside from the ownership-check gap already noted.pinbox.profiles.lastSelectedOffice/lastSelectedWorkspace/lastLoginTime/wrongPassCount/wrongPassTime— session/UX state, safe to reset for troubleshooting (e.g. clearingwrongPassCountto unlock an account after the lockout inauth.helper.ts:14,52-55,136).
Related
- Issue #3826 — origin,
pinbox24_com_power_userprovisioning gap. docs/playbooks/pinbox24-office-membership-grant.md— empirically-verified live-production behavior for granting office visibility; read together with the “Known discrepancy” section above.docs/pinbox24/gitlab-repo-locations.md— GitLab clone pattern and credential handling used for this investigation.docs/w3-w4-stack-operations.md— ownership/permission matrix, persistent-patches gotcha (deployed-vs-source drift risk referenced above).docs/playbooks/secret-manager-request.md— how to store a new service-account password once created, instead of leaving it only in a chat/script.