// The forum's access model, its switches, its renderer (phase 4, "5a") and its // discussion half (phase 5, "5b") — docs/website/TEAMS.md Part 5. // // The tests named "acceptance" are the phases' stated acceptance criteria, // verbatim. They are the ones to read first, and the ones not to weaken: each // names a property that the code around it can lose without any screen looking // different. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const forumSettings = require('../src/model/teams/teamForumSettings.model') const settingsDb = require('../src/model/settings/settings.db') const accessDb = require('../src/model/teams/teamAccess.db') const teamsDb = require('../src/model/teams/teams.db') const usersDb = require('../src/model/users/users.db') const grants = require('../src/model/teams/teamGrants.model') const access = require('../src/model/teams/teamAccess.model') const forum = require('../src/model/teams/teamForum.model') const forumDb = require('../src/model/teams/teamForum.db') const uploads = require('../src/model/teams/teamForumUploads.model') const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml') const saved = [] function patch(mod, name, fn) { saved.push([mod, name, mod[name]]) mod[name] = fn } // One settings store per test, so a test states the keys it cares about and // nothing else. `get` returning undefined is "the row does not exist", which for // both forum keys is the default and therefore the OFF state. let store = {} function stubSettings() { store = {} patch(settingsDb, 'get', async (key) => store[key]) patch(settingsDb, 'getRow', async (key) => (key in store ? { key, value: store[key], updated_by: 1, updated_by_username: 'root', updated_at: new Date() } : null)) patch(settingsDb, 'set', async (key, value) => { store[key] = value }) } beforeEach(() => { stubSettings() }) afterEach(() => { while (saved.length) { const [mod, name, original] = saved.pop() mod[name] = original } }) // ── the switch (§5.5.1) ──────────────────────────────────────────────────── test('the forum is off until an operator turns it on, and a broken read keeps it off', async () => { assert.equal(await forumSettings.forumsEnabled(), false) store.teams_forums_enabled = '1' assert.equal(await forumSettings.forumsEnabled(), true) // Fail closed. A transient DB fault must not open a feature the operator // deliberately turned off — a forum that 404s for a minute is the cheap failure. patch(settingsDb, 'get', async () => { throw new Error('db down') }) assert.equal(await forumSettings.forumsEnabled(), false) }) test('an unexpected stored image mode reads as disabled rather than as itself', async () => { store.teams_forum_images = 'everything' assert.equal(await forumSettings.imageMode(), 'disabled') }) // ── the acknowledgement gate (§5.5.5) ────────────────────────────────────── test('acceptance 4: uploads mode is rejected without a matching acknowledgement', async () => { // Server-side, with the admin UI's checkbox bypassed — a checkbox is how the // gate is presented and never the gate. Nothing is on record and the stored // mode is not uploads, so this is a genuine transition INTO it. const refused = await forumSettings.assertAcknowledged('uploads', undefined) assert.equal(refused.ok, false) assert.equal(refused.status, 400) // A STALE version is not an acknowledgement either. assert.equal((await forumSettings.assertAcknowledged('uploads', '0')).ok, false) assert.equal((await forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION)).ok, true) }) test('the other two image modes need no acknowledgement', async () => { // `remote` gets a non-blocking advisory instead: nothing comes to rest on the // operator's disk, which is the thing the acknowledgement is about. assert.equal((await forumSettings.assertAcknowledged('remote', undefined)).ok, true) assert.equal((await forumSettings.assertAcknowledged('disabled', undefined)).ok, true) }) test('uploads is not a one-way door — a later save needs no fresh acknowledgement', async () => { // Found on the live rig. A settings form sends every field it owns, so with // uploads on, unticking "Enable Team forums" re-sends `uploads` and came back // 400 — the operator could never change a forum setting again, least of all the // one they would reach for in a hurry. store.teams_forum_images = 'uploads' store.teams_forum_uploads_ack = forumSettings.ACK_VERSION assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, true) // Still a gate where consent is genuinely absent: a stale acknowledgement means // the wording moved, and that DOES need re-consent. store.teams_forum_uploads_ack = '0' assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false) // And a transition INTO uploads from another mode still asks. store.teams_forum_images = 'remote' store.teams_forum_uploads_ack = forumSettings.ACK_VERSION assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false) }) test('a reworded notice freezes forum settings but does NOT disable uploads', async () => { store.teams_forum_uploads_ack = '0' // accepted an older wording store.teams_forum_images = 'uploads' const state = await forumSettings.ackState() assert.equal(state.stale, true) assert.equal(state.given, true) // Uploads keep working: silently downgrading a live feature because a legal // text changed would strand users mid-conversation. assert.equal(await forumSettings.uploadsEnabled(), true) const frozen = await forumSettings.assertSettingsWritable(['teams_forums_enabled'], undefined) assert.equal(frozen.ok, false) // Re-acknowledging is the key to its own lock. const unlocked = await forumSettings.assertSettingsWritable( ['teams_forums_enabled'], forumSettings.ACK_VERSION, ) assert.equal(unlocked.ok, true) }) test('a setting that is not the forum’s is unaffected by a stale acknowledgement', async () => { store.teams_forum_uploads_ack = '0' const result = await forumSettings.assertSettingsWritable(['site_title'], undefined) assert.equal(result.ok, true) }) // ── the renderer (§5.5.3) ────────────────────────────────────────────────── test('acceptance 3: the stored HTML is identical in every image mode', () => { const stored = cleanForumBody('
Banner: https://example.com/banner.png
') // The author wrote a URL and it was stored as a LINK. No
http://x.test/a.png
'), 'remote') assert.ok(!httpUrl.includes('/uploads/1787-abc.png
') assert.match(stored, /]*src="\/uploads\/1787-abc\.png"/) assert.equal(renderForumBody(stored, 'disabled'), stored) }) test('ordinary prose containing a slash is not turned into a link', () => { // The upload branch is deliberately narrow — `/uploads/` and nothing else. assert.ok(!cleanForumBody('meet at /the docks tonight
').includes(' { const stored = cleanForumBody('https://example.com/a.png') assert.ok(!stored.includes(' { const stored = cleanForumBody('x') assert.match(stored, /rel="noopener noreferrer nofollow"/) assert.ok(!stored.includes('rel="me"')) }) // ── grants: authority, the cap, and non-contamination (§2.5) ─────────────── const team = { id: 1, name: 'Ossuary' } const leader = { id: 7, username: 'aldric', role: 'player' } const staff = { id: 2, username: 'root', role: 'admin' } const guest = { id: 9, username: 'mara', role: 'player' } function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) { patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId)) patch(accessDb, 'activeGrant', async () => existing) patch(accessDb, 'activeGrantCount', async () => activeCount) patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null)) patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null) } test('acceptance 1: a granted account has forum access and is not a member', async () => { stubGrantWorld() const written = [] patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 }) // The membership projection is stubbed to a table nothing may write. If the // grant path touched it, these would be the rows that changed. const membersBefore = [] patch(teamsDb, 'membersByTeam', async () => membersBefore) patch(teamsDb, 'activeByUser', async () => undefined) const result = await grants.grant({ team, actor: leader, username: 'mara' }) assert.equal(result.ok, true) assert.equal(written.length, 1) assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle // The resolver now says yes, and says WHY separately. patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id })) const resolved = await access.forumAccess(team.id, guest.id) assert.equal(resolved.allowed, true) assert.equal(resolved.viaGrant, true) assert.equal(resolved.viaMembership, false) // …and path 4 still refuses, because an integration cannot verify that an // unlinked, forum-granted account is a real game member. assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false) }) test('a leader is capped; staff are not, and are warned on the way past', async () => { stubGrantWorld({ activeCount: 50 }) patch(accessDb, 'insertGrant', async () => 1) const refused = await grants.grant({ team, actor: leader, username: 'mara' }) assert.equal(refused.ok, false) assert.equal(refused.status, 409) const allowed = await grants.grant({ team, actor: staff, username: 'mara' }) assert.equal(allowed.ok, true) assert.match(allowed.warning, /limit of 50/) }) test('a leader may not revoke a staff-issued grant', async () => { stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } }) patch(accessDb, 'revokeGrant', async () => true) const refused = await grants.revoke({ team, actor: leader, userId: guest.id }) assert.equal(refused.ok, false) assert.equal(refused.status, 403) // Staff may. This is what stops a leader undoing a moderation decision. const allowed = await grants.revoke({ team, actor: staff, userId: guest.id }) assert.equal(allowed.ok, true) }) test('an account that has lost its staff role stops protecting the grants it made', async () => { // Checked at REVOKE time against the issuer's current role, not against a flag // stored when the grant was made — which is the behaviour an operator demoting // someone expects. const demoted = { id: 2, username: 'root', role: 'player' } stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } }) patch(usersDb, 'findById', async () => demoted) patch(accessDb, 'revokeGrant', async () => true) const result = await grants.revoke({ team, actor: leader, userId: guest.id }) assert.equal(result.ok, true) }) test('a member who is also a grantee is listed as a member, not as a guest', async () => { patch(accessDb, 'activeGrants', async () => [ { user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null }, { user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null }, ]) patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }]) const guests = await grants.forumGuests(team.id) assert.deepEqual(guests.map((g) => g.username), ['mara']) }) // ── threads (§5.1, §5.3) ─────────────────────────────────────────────────── test('both thread types are creatable, and an invented one is not', async () => { patch(forumDb, 'insertThread', async () => 1) patch(forumDb, 'insertPost', async () => 1) // Phase 5 opened `discussion`. Neither type needed a migration: both have been // in the enum since 5a, which is what §5.1's split-by-layer bought. for (const type of ['announcement', 'discussion']) { const ok = await forum.createThread({ team, actor: leader, type, title: 'Raid', body: '
Hi
' }) assert.equal(ok.ok, true, type) } const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: 'Hi
' }) assert.equal(refused.ok, false) assert.equal(refused.status, 400) }) test('an announcement with only markup for a body is refused', async () => { patch(forumDb, 'insertThread', async () => 1) patch(forumDb, 'insertPost', async () => 1) const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '' }) assert.equal(refused.ok, false) }) test('moderation records WHICH authority was exercised', async () => { const ledger = [] patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' })) patch(forumDb, 'setThreadFlags', async () => true) patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) }) await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' }) await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' }) assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff']) assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide']) }) test('a thread id from another Team reads as not found', async () => { patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' })) const result = await forum.getThread(1, 5, { canModerate: true }) assert.equal(result, null) }) test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => { patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 })) patch(forumDb, 'postsByThread', async () => []) assert.equal(await forum.getThread(1, 5, { canModerate: false }), null) assert.ok(await forum.getThread(1, 5, { canModerate: true })) }) // ── uploads (§5.5.4) ─────────────────────────────────────────────────────── test('magic bytes decide the type, not the client’s Content-Type header', () => { const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(8), ]) assert.equal(uploads.sniff(png), 'image/png') // A player can send `image/png` with arbitrary bytes. Unrecognised is a // rejection, never a fallback to what the header claimed. assert.equal(uploads.sniff(Buffer.from(' ')), null) assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge }) test('a RIFF container that is not WebP is not accepted as one', () => { const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)]) assert.equal(uploads.sniff(wav), null) }) // ── phase 5 ("5b"): replies, the edit window, post moderation ────────────── const member = { id: 11, username: 'wanderer', role: 'player' } // A visible discussion thread and one post in it, as the DB layer would return // them. Written as a factory rather than a shared constant because half these // tests mutate the row they are given. const discussion = (over = {}) => ({ id: 5, team_id: 1, type: 'discussion', title: 'Raid night', status: 'visible', locked: 0, pinned: 0, post_count: 1, created_by: 11, created_username: 'wanderer', ...over, }) const post = (over = {}) => ({ id: 80, thread_id: 5, author_user_id: 11, author_username: 'wanderer', body_html: 'Hi
', status: 'visible', created_at: new Date(), edited_at: null, edited_by: null, ...over, }) test('a reply lands on a discussion thread and never on an announcement', async () => { patch(forumDb, 'insertPost', async () => 81) patch(forumDb, 'threadById', async () => discussion()) const ok = await forum.createPost({ team, threadId: 5, actor: member, body: 'Count me in
' }) assert.equal(ok.ok, true) assert.equal(ok.postId, 81) // 400, not 404 and not 409: the request is malformed FOR THIS THREAD and no // amount of retrying fixes it. An announcement takes no replies by TYPE. patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' })) const refused = await forum.createPost({ team, threadId: 5, actor: member, body: 'Hi
' }) assert.equal(refused.ok, false) assert.equal(refused.status, 400) }) test('a locked thread refuses replies with 409 — and refuses staff too', async () => { patch(forumDb, 'insertPost', async () => { throw new Error('must not write') }) patch(forumDb, 'threadById', async () => discussion({ locked: 1 })) for (const actor of [member, leader, staff]) { const refused = await forum.createPost({ team, threadId: 5, actor, body: 'Hi
' }) assert.equal(refused.ok, false, actor.username) // Well-formed request, refusing STATE — which is the distinction a client // needs to tell "you cannot" from "not right now". assert.equal(refused.status, 409, actor.username) } }) test('a reply to a hidden or foreign thread reads as not found', async () => { patch(forumDb, 'insertPost', async () => { throw new Error('must not write') }) patch(forumDb, 'threadById', async () => discussion({ status: 'hidden' })) assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: 'x
' })).status, 404) patch(forumDb, 'threadById', async () => discussion({ team_id: 999 })) assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: 'x
' })).status, 404) }) test('the edit window is decided on the server, from created_at', () => { const fresh = post({ created_at: new Date(Date.now() - 60_000) }) // a minute old const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) // an hour old assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 15 }).canEdit, true) assert.equal(forum.editability(stale, { userId: 11, windowMinutes: 15 }).canEdit, false) // Somebody else's post, inside the window, is still not theirs to edit. assert.equal(forum.editability(fresh, { userId: 99, windowMinutes: 15 }).canEdit, false) // Staff are not time-bounded, and `editableUntil: null` reads as "no deadline" // rather than as "no permission" — canEdit is the permission. const asStaff = forum.editability(stale, { userId: 2, isStaff: true, windowMinutes: 15 }) assert.equal(asStaff.canEdit, true) assert.equal(asStaff.editableUntil, null) // A window of zero is a legitimate operator choice: posts immutable once written. assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 0 }).canEdit, false) }) test('a hidden post is editable by nobody, staff included', () => { const hidden = post({ status: 'hidden', created_at: new Date() }) assert.equal(forum.editability(hidden, { userId: 11, windowMinutes: 15 }).canEdit, false) // Restoring it is a moderation action with a ledger row; quietly rewriting it // while it is out of sight is the same act with no record. assert.equal(forum.editability(hidden, { userId: 2, isStaff: true, windowMinutes: 15 }).canEdit, false) }) test('the write path re-derives the window and does not trust the read path', async () => { const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) patch(forumDb, 'postById', async () => stale) patch(forumDb, 'threadById', async () => discussion()) const writes = [] patch(forumDb, 'updatePostBody', async (...args) => { writes.push(args); return true }) const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: 'new
' }) assert.equal(refused.ok, false) assert.equal(refused.status, 403) assert.equal(writes.length, 0) // Staff, same post, same moment. const allowed = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: 'new
' }) assert.equal(allowed.ok, true) assert.equal(writes.length, 1) // Reported so the controller can write the §5.3 accountability row — a staffer // editing someone ELSE's words is an intervention. assert.equal(allowed.staffEdit, true) }) test('a staffer editing their own post is an ordinary edit, not an intervention', async () => { patch(forumDb, 'postById', async () => post({ author_user_id: staff.id, author_username: staff.username })) patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'updatePostBody', async () => true) const result = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: 'x
' }) assert.equal(result.ok, true) assert.equal(result.staffEdit, false) }) test('a member may not edit somebody else’s post at all', async () => { patch(forumDb, 'postById', async () => post({ author_user_id: 99, author_username: 'someone' })) patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'updatePostBody', async () => { throw new Error('must not write') }) const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: 'x
' }) assert.equal(refused.ok, false) assert.equal(refused.status, 403) }) test('post moderation shares the thread ledger, tagged as a post', async () => { const ledger = [] patch(forumDb, 'postById', async () => post()) patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'setPostStatus', async () => true) patch(forumDb, 'recountThread', async () => {}) patch(forumDb, 'softDeleteUploadsForPost', async () => {}) patch(forumDb, 'restoreUploadsForPost', async () => {}) patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) }) await forum.moderatePost({ team, postId: 80, action: 'hide', actor: leader, actorRole: 'leader' }) await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' }) // One table, two target kinds — so "everything moderated in this Team" stays // one query instead of a union. assert.deepEqual(ledger.map((r) => r.targetType), ['post', 'post']) assert.deepEqual(ledger.map((r) => r.action), ['hide', 'delete']) assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff']) }) test('pin and lock are refused BY NAME on a post, not as unknown actions', async () => { patch(forumDb, 'postById', async () => post()) patch(forumDb, 'threadById', async () => discussion()) const wrongObject = await forum.moderatePost({ team, postId: 80, action: 'pin', actor: leader, actorRole: 'leader' }) assert.equal(wrongObject.status, 400) assert.match(wrongObject.error, /applies to a thread/) const nonsense = await forum.moderatePost({ team, postId: 80, action: 'incinerate', actor: leader, actorRole: 'leader' }) assert.equal(nonsense.status, 400) assert.match(nonsense.error, /Unknown/) }) test('deleting a post takes its images with it, and restoring brings them back', async () => { const calls = [] patch(forumDb, 'postById', async () => post()) patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'setPostStatus', async () => true) patch(forumDb, 'recountThread', async () => {}) patch(forumDb, 'insertModeration', async () => {}) patch(forumDb, 'softDeleteUploadsForPost', async (id) => { calls.push(['soft', id]) }) patch(forumDb, 'restoreUploadsForPost', async (id) => { calls.push(['restore', id]) }) await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' }) await forum.moderatePost({ team, postId: 80, action: 'restore', actor: staff, actorRole: 'staff' }) // Without the second half, delete → restore returns the words and loses the // pictures a retention window later, silently. assert.deepEqual(calls, [['soft', 80], ['restore', 80]]) // Hiding is not deleting: a hidden post's images are untouched, because // unhiding must be free. calls.length = 0 await forum.moderatePost({ team, postId: 80, action: 'hide', actor: staff, actorRole: 'staff' }) assert.deepEqual(calls, []) }) test('post moderation recomputes the thread’s counters rather than nudging them', async () => { const recounts = [] patch(forumDb, 'postById', async () => post()) patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'setPostStatus', async () => true) patch(forumDb, 'insertModeration', async () => {}) patch(forumDb, 'softDeleteUploadsForPost', async () => {}) patch(forumDb, 'restoreUploadsForPost', async () => {}) patch(forumDb, 'recountThread', async (id) => { recounts.push(id) }) // hide → unhide → hide is a cycle a counter kept by deltas gets wrong the // first time a step is retried or raced. for (const action of ['hide', 'unhide', 'hide']) { await forum.moderatePost({ team, postId: 80, action, actor: staff, actorRole: 'staff' }) } assert.deepEqual(recounts, [5, 5, 5]) }) test('a thread reports whether it takes replies, and why not', async () => { patch(forumDb, 'postsByThread', async () => []) patch(forumDb, 'threadById', async () => discussion()) assert.equal((await forum.getThread(1, 5, { canModerate: false })).canReply, true) patch(forumDb, 'threadById', async () => discussion({ locked: 1 })) const locked = await forum.getThread(1, 5, { canModerate: false }) assert.equal(locked.canReply, false) assert.equal(locked.locked, true) // the UI can say WHICH half refused patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' })) const announcement = await forum.getThread(1, 5, { canModerate: false }) assert.equal(announcement.canReply, false) assert.equal(announcement.type, 'announcement') }) test('every post comes back knowing whether THIS reader may edit it', async () => { patch(forumDb, 'threadById', async () => discussion()) patch(forumDb, 'postsByThread', async () => [ post({ id: 80, author_user_id: 11, created_at: new Date() }), post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }), ]) const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } }) assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false]) assert.deepEqual(mine.posts.map((p) => p.mine), [true, false]) // A caller that does not say who is reading gets the safe answer, which is what // keeps every phase-4 call site correct without changing it. const anonymous = await forum.getThread(1, 5, {}) assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false]) })