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 constantModel name stringActual 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)

EndpointMethodFile:line
/api/registrationPOSTsrc/apps/auth/auth.route.ts:47-60registerToPlatform, auth.controller.ts:94-143
/api/signupPOSTauth.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-otpPOSTsignupOtpToPlatform, 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 from checkProfileAndCreate (src/globalHelpers/notifications/webhook.helper.ts:179-235) and resetPasswordFromTask (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 gatesrc/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-39office.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-160office.controller.ts:384-414createNewOfficeHelperV2, 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 (userInvitationsoffice.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-13inviteUser, 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 (officeGuardMiddleWarecheckAuthUserOfOffice, 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)

  1. Looks up the invitation by id with status in [sended,pending,accountCreated] (:23-25), re-resolves invitedToId by looking up the profile from invitedToEmail (:29not from the caller’s JWT), checks expiry (checkInvitationExpiration, :31,70-80 — a moment() comparison at read time, not a TTL index and not cron-enforced, see expiry section below).

  2. Resolves workspaceId by slug if not already set (:34-38).

  3. Runs three writes in parallel (:40-44):

    a) updateOfficeDoc (:99-119) — no-op if isOfficeUser already true, else creates the office.users document:

    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: false on the invitation (:115) — this looks like a bug (should plausibly be true); 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 workspace RegisterModel.users[] array (dedup by userEmail), sets isWorkspaceUser: true.

    c) addToUserProfile (:142-179) — the only place pinbox.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’s workspaces[] if that specific workspace isn’t there yet.

  4. Sends acceptance emails + a Discord notification, sets invitation status:"accepted" (:51).

Note the uid format is inconsistent across code pathsacceptUserInvitation.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 (isExpired in userInvitation.controller.ts:66,123; pinboxProfile.helper.ts:239-244 for a dashboard count).
  • A cron-triggered helper invitationExpireCron (userInvitation.helper.ts:220-250, wired at src/apps/cronScript/cronScript.route.ts:38) sends reminder/expired emails only (day-before, day-after) — it never writes status:"expired" or deletes anything. “Expired” is a purely virtual state, recomputed everywhere from expirationDate.
  • Manual revocation before expiry: GET /api/offices/user-invitation/:userInvitationId/cancelstatus:"canceled" (userInvitation.controller.ts:193-200).

All invitation endpoints

EndpointMethodAuthFile:line
/api/offices/invitationPOSTJWT + officeId header, office memberuserInvitation.route.ts:8-13
/api/offices/user-invitation-un-auth/:userInvitationIdGETnoneuserInvitation.route.ts:41-51
/api/offices/user-invitation-un-auth/:userInvitationId/set-passwordPOSTnoneuserInvitation.route.ts:53-69
/api/offices/user-invitation/:invitationId (re-invite)GETJWTuserInvitation.route.ts:29-39
/api/offices/user-invitation/:userInvitationId/migrate-invitationPOSTJWTuserInvitation.route.ts:71-88
/api/offices/user-invitation/:userInvitationId/rejectGETJWTuserInvitation.route.ts:90-100
/api/offices/user-invitation/:userInvitationId/cancelGETJWTuserInvitation.route.ts:102-112
/api/offices/user-invitation/:userInvitationId/acceptGETJWTuserInvitation.route.ts:114-124
/api/offices/user-invitation/:userInvitationId/deleteDELETEJWTuserInvitation.route.ts:126-136
/api/offices/user-invitation/list, /countGETJWTuserInvitation.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 the role of an already-existing workspace member; it cannot add a new one.
  • DELETE /api/offices/work-space/:slug/users/:userId removes a workspace member — works, but is removal, not assignment.
  • src/apps/officeUsers/officeUsers.controller.ts is an empty file; officeUsers.model.ts / officeUsers.helper.ts expose only getOfficeUserById (Redis-cached lookup, used internally by officeGuardMiddleWare and office.helper.ts) — no routes are registered for this module at all (not imported into app.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, backs GET /api/offices) reads only pinbox.profiles.offices[] to get the list of office IDs, then fetches those OfficeModel docs by _id — it does not read or filter on any offices.users[] field at all.
  • The write to the embedded offices.users[] array is present in source but commented out (office.helper.ts:372-386, inside addUserToOffice) — 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 officeGuardMiddleWare for officeId-scoped calls (checkAuthUserOfOffice, src/middleware/officeGaurd.middleware.ts:252-264) queries the standalone office.users collection (getOfficeUserById, officeUsers.helper.ts:7-26, Redis-cached under key oUser:${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.

  1. Decide the target office(s) and workspace(s) the service account needs. Get the officeId (ObjectId) and the workspace slug for each. If no suitable office exists yet, POST /api/offices/new first, using a real logged-in admin’s JWT (the caller becomes that office’s isAdmin).

  2. Create the profile. Two options:

    • Preferred — via invitation (see step 3; setPasswordInvitation.helper.ts creates the profile automatically as part of accept for a brand-new email, no separate call needed).
    • Direct registrationPOST /api/registration with {email, password} using the service account’s intended login email. Scriptable, no auth required. Creates pinbox.profiles with a plaintext pass — 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).
  3. Invite the profile to the office + workspace, as an existing office member (any member, not just admin): POST /api/offices/invitation with officeId header 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.

  4. 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 via GET /api/offices/user-invitation/list with the inviter’s own JWT — it lists invitations that inviter sent) and calling the JWT-gated GET /api/offices/user-invitation/:userInvitationId/accept using a JWT for the invited account itself (log in as the service account first via POST /api/auth once step 2/3 gave it a password), or (b) if the profile didn’t exist before the invitation, calling the un-auth POST /api/offices/user-invitation-un-auth/:userInvitationId/set-password with 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 against invitedToEmail), 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.

  5. 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).

  6. Verify. Log in as the service account (POST /api/auth, password sent as MD5 hex of the plaintext per the client-side convention documented in docs/playbooks/pinbox24-office-membership-grant.md step 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), then GET /api/offices should 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 embedded users[] array too.

  7. Never hand-insert pinbox.profiles or office.users documents directly in MongoDB as a substitute for steps 2–4 unless every field this playbook documents (correct pass encoding, correct uid format sampled from a real doc in the target office, groups/isAdmin defaults, the pinbox.profiles.offices[].workspaces[] entry) is reproduced by hand — partial hand-inserts are exactly how pinbox24_com_power_user ended up broken in the first place (#3826). If a manual DB write is unavoidable (e.g. because of the discrepancy in the previous section), follow docs/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 creation
  • POST /api/offices/new — office creation
  • POST /api/offices/invitation/send (inviteToOffice) — direct office add, no invitation/email step, no admin gate (any office member’s token); does not assign a workspace
  • POST /api/offices/invitation (inviteUser) — create invitation(s) + trigger email
  • GET /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 emails
  • PUT /api/offices/:officeId/users/:email (updateOfficeSingleUser, office.controller.ts around line 1211) — direct upsert of office.users fields (groups, isAdmin) for an existing office member, scriptable, requires office membership
  • DELETE /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 _id is 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.uidunique: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 by officeGuardMiddleWare and 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; status can 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. clearing wrongPassCount to unlock an account after the lockout in auth.helper.ts:14,52-55,136).

  • Issue #3826 — origin, pinbox24_com_power_user provisioning 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.