Skip to content

Commit 3a23820

Browse files
waleedlatif1icecrasher321emir-karabegTheodoreSpeaks
authored
feat(search): add organization search and private assistant (#7477)
* feat(knowledge): resolve connector tokens through one service-account-aware resolver A connector's access token was resolved three different ways — once in the sync engine and twice in the connector application layer — each calling `refreshAccessTokenIfNeeded` with no scopes. Google's service-account resolver throws `Scopes are required for service account credentials` without them, so a service-account credential could not authenticate a connector at all: creation failed config validation, and a connector that got past it failed at its first mint. The string wrapper also discarded the `cloudId` an Atlassian service account resolves with, which is the only way such a credential can name its site — its API token cannot call `accessible-resources` to discover one. `resolveConnectorAccessToken` now owns that resolution for all three call sites, turning a connector's declared `ConnectorAuthConfig` plus its credential or key into a token bundle. `serviceAccountScopes` lets a connector declare the scopes its provider accepts in a two-legged grant where those differ from the ones its consent screen asks for, defaulting to `requiredScopes` where they coincide. A resolved cloud id seeds the same `syncContext` slot the connector would memoise it into, so no connector needs a service-account branch of its own. `getMissingRequiredScopes` now reports nothing missing for a service account: it names its scopes per request and has no granted-scope list, so measuring it against the required set flagged every scope missing and offered a reconnect that would have granted nothing. With both fixed, the add-connector modal stops filtering service accounts out of its credential list. * feat(knowledge): map Google Drive permissions to document access tokens The mirrored-ACL model needs one place that decides who a Drive file is readable by, and it is the piece with the least margin for error: a wrong arm here publishes a document rather than failing a request. `driveFileAcl` maps a file's `permissions[]` to the token vocabulary the document ACL already speaks — `u:` for a named person, `g:` for a group, `pub` for a genuinely public file — with two rules worth stating outright. An open share grants nothing unless an admin has opted in for that connector. A file shared to a whole domain, or to anyone who finds it, is usually shared that way by accident, and its contents are exactly what nobody meant to publish; an admin who knows their domain's sharing hygiene can turn it on. A link-only share never grants, opt-in or not. `allowFileDiscovery: false` is Drive's own "reachable by link, findable by nobody", and Drive excludes those files from its search for the same reason we exclude them from ours. Onyx's file path misses this — it makes any `anyone` grant public without reading the flag, though its folder path does read it — so a link pasted once would make a document searchable by the whole workspace. Group membership and shared-drive membership are deliberately not resolved here. Both are directory state; expanding them per file would re-read the directory once per document, so the group is recorded as a token and the directory sync resolves it. A file whose every grant is unrepresentable resolves to `link` rather than an empty ACL, keeping "hidden on purpose" distinguishable from "hidden because we failed". * feat(knowledge): write mirrored document ACLs without re-embedding Admin mode mirrors a source's own permissions onto each document, and the whole point is that permissions change far more often than content: somebody joins a group, a folder is reshared. So the ACL write cannot be a field on the content write. A document the sync classifies as unchanged never reaches the document update path at all, and that path sets `processingStatus: 'pending'` — the sole trigger of re-embedding — so routing an ACL change through it would re-embed a corpus every time a group membership changed. `persistDocumentAcls` assigns `acl` and nothing else, leaving `contentHash`, `processingStatus`, `chunkCount` and the embedding rows untouched. Documents are grouped by identical ACL before writing, since files under one folder overwhelmingly share theirs, so a crawl of thousands resolves to a handful of statements; `IS DISTINCT FROM` means a re-run that changes nothing writes nothing, which is what lets permissions sync on a faster clock than content. Both properties are covered by tests that fail if the assignment widens. `SyncDocumentAccess` gains an `admin` arm alongside `members`. Both derive their ACL from something the content sync does not know, so both are born hidden and made visible by a separate pass — a document indexed before its permissions are known is invisible, never workspace-wide. `validateAcl` enforces a 5,000-token ceiling and the token shapes the database constrains. A document whose ACL we cannot store is stored as readable by nobody rather than skipped, because leaving the previous ACL in place would keep serving it under permissions we just failed to verify. Onyx declares the same ceiling and, by its own comment, never enforces it; catching it here also names the offending document instead of failing whichever batch it shared a statement with. * feat(knowledge): crawl Google Drive as an administrator and mirror its permissions Completes the admin-mode path end to end for Drive: one crawl under a service account, each document stored with the ACL the source itself reports. A service account owns nothing in a Workspace domain, so a crawl under one sees an empty corpus until it impersonates somebody. The subject lives on the connector rather than the credential — one `google-service-account` credential matches every Google service, so a subject stored on it would silently apply to a workflow reading that person's mail as well as to this crawl reading their Drive. `serviceAccountSubjectFieldId` names the config field holding it, and the service-account scope set is read-only and narrower than the interactive one: a crawl that reads every file in a domain should never hold write access. That administrator's email domain is also the tenant segment of every group token the crawl writes. It has to be settled before the first token is stored, because deriving it differently later would orphan every ACL already written — which is why Drive could not be switched on until the subject existed. The listing now requests `permissions` and `permissionIds` together, because Drive sometimes reports more ids than it expands. A file whose two counts disagree is left readable by nobody rather than mirrored under the subset that arrived: that sounds like the safe direction but is not, since the grants that went missing are exactly the ones nobody verified. The ACL pass runs over the whole listing rather than the documents whose content changed. A membership or sharing change moves no content, so restricting it to changed documents would let a revoked grant stay readable until somebody happened to edit the file. It runs after the content pass, so a document this run inserted — born hidden — is present to be made readable, and before reconciliation, so a revoked grant lands even on a run that removes nothing. The access-mode vocabulary moves into one leaf module. Three separate queries — the scheduler's due sweep, the queue's dispatch claim, and the engine's own lock — each hard-coded `accessMode = 'workspace'`, so widening the engine without widening all three would have left admin-mode connectors dispatchable but never dispatched, or worse, dispatched and then refused. They now read one constant. * fix(knowledge): make one email address mean one account `user.email` is unique byte-for-byte only, so `Alice@corp.com` and `alice@corp.com` can both exist as separate accounts. Every identity binding in the product compares the case-folded address, so those two rows are one identity to it: a credential-group enrollment for either matches both, and each account receives the `s:` subject token of a managed credential belonging to the other. That is live on the members-mode path today, and it is the same fold the `u:` document token depends on. `user_email_lower_unique` is the constraint that makes the state unreachable — the standard Postgres form of a case-insensitive unique email, on `lower(btrim(email))`. Access resolution probes that exact expression, so a predicate written any other way would silently become a sequential scan of `user` on every read; verified as an index scan against a real database. The migration pre-checks for duplicates inside the runner's batch transaction, so a database that already holds one fails the deploy with a sentence naming the problem and rolls back having changed nothing. Without that check the concurrent build would fail on the first duplicate and leave an INVALID index that `IF NOT EXISTS` skips on every later run — the constraint would appear to exist while enforcing nothing, which is the one outcome worth engineering against. Both paths were verified against a real database in rolled-back transactions: the index builds on current data, and a deliberately inserted case-variant duplicate raises and rolls back. Access resolution keeps its own ambiguity check rather than trusting the constraint to still be there. An index can be dropped during an incident, and a restore can bring back a database built before it existed; neither should silently hand one person another's documents. An ambiguous address binds to nothing, so both accounts keep the tokens every workspace member holds and lose only what their identity would have granted. The enrollment join also stops reading `normalized_email`. That column is declared unique but written by nothing, so the `COALESCE` over it always fell through to the folded address — and would have silently started matching a broader set of people the day anything backfilled it. * feat(knowledge): resolve directory groups so mirrored grants reach the right people A mirrored ACL names a group; nothing until now said who was in one, so a document Drive shared with a group was readable by nobody. This closes the loop: the directory is enumerated into `knowledge_external_group` and its membership, and access resolution turns a reader's address into the `g:` tokens they hold. Groups are scoped by workspace, provider and tenant rather than by connector — two connectors over one Google Workspace domain grant the same groups, and resolving the directory once per connector would multiply Admin SDK traffic by the number of knowledge bases. Membership is keyed by case-folded email rather than Sim user id, because a directory reports addresses and most members of a granted group have no Sim account; storing the address means someone who signs up later inherits their grants on first read, with no backfill. Nested groups are flattened. Onyx reads one level and stops, so a person who belongs only through a subgroup silently gets nothing even though the source grants them access. The walk carries a visited set and a depth bound, because directories nest arbitrarily and will happily report a cycle. The unit of work is a group, not the directory. A group that enumerates completely is replaced in one transaction; one that does not is left exactly as it was, with a failure recorded and its `lastSyncedAt` untouched. That is the deliberate departure from Onyx, whose sync marks every row stale, upserts what the source returned and sweeps the rest — clean until the directory half-fails, at which point it revokes real members whose rows simply were not returned. Here an outage costs freshness and nothing else. That patience needs a bound, or a sync that stopped running would keep granting forever from membership nobody has checked. A group unconfirmed for longer than `EXTERNAL_GROUP_STALE_AFTER_MS` stops granting: an outage is survivable, an abandoned sync is not. Verified against a real database — the read plans as index scans on both sides, and a directory pushed past the window drops from 500 matching groups to none. The refresh runs inside the admin-mode crawl, before ACLs are written, so a crawl can never publish grants against membership this workspace has never read. It is rate-limited on its own clock so a frequently-syncing connector does not re-read the directory every run, and a failure is logged rather than thrown — last-known-good membership is still serving reads, and failing the content sync over it would strand the documents as well as the groups. * feat(knowledge): let a connector be put into administrator mode The mirroring path was complete but unreachable: `accessMode` accepted only `workspace` and `members`, so nothing could ask for the mode the last four commits built. This makes it selectable, end to end. The mode-switch matrix stays linear rather than growing to three-by-three. Both credential-backed modes change the same way — swap the credential, keep the documents — so they share one fast path, and `members` remains the special case it always was. What entering a mode does to existing ACLs moves into one table: `workspace` publishes to the workspace, `members` and `admin` both hide, because in both the ACL belongs to a pass that has not run yet. Hiding on entry is what makes an interrupted switch safe — documents are hidden early, never shown early — and the exit branch now reads that table instead of hard-coding `WORKSPACE_ACL`. Administrator mode takes the same role as members mode. Both decide whose data the workspace indexes, and both refuse rather than warn when the connector cannot deliver: a source that reports no per-document permissions has no administrator mode, and one that has not been told which administrator to crawl as would index every document readable by nobody — indistinguishable from a broken sync. Failing when the mode is chosen says what is missing while the person choosing it can still supply it. `currentAccess` in the edit modal stops folding unknown modes into Workspace. That catch-all would have told an admin their documents were visible to the whole workspace when they were not, and silently rewritten the mode on the next save. The engine-ownership rule is now covered by a test that walks every mode: the content and member engines hold mutually exclusive leases, so a mode claimed by both — or by neither — is a connector that either never runs or runs twice. * fix(knowledge): gate mirrored access on its own feature, not on Credential Groups One availability check governed everything permission-aware, and half of it was about Credential Groups. Administrator mode mirrors a source's own ACLs and touches no Credential Group, so an operator turning that feature off would have silently revoked every document an administrator crawl had mirrored — from a feature it does not use. The check now answers two questions from one billing read: whether source- mirrored access is available, and whether member-scoped access is. Both are enterprise features on Sim Cloud and both sit behind the same kill switch, so turning permission-aware knowledge off still hides every permission-scoped document on the next read; only the Credential Groups clause is now scoped to the mode that needs it. They are returned as a pair so a caller cannot check one and act on the other. Access resolution mints each token family under its own answer, and choosing administrator mode is refused when the workspace is not entitled to it — before a crawl indexes a corpus whose ACLs nobody would be able to match. * refactor(knowledge): drop directory-group columns nothing reads `display_name`, `consecutive_failures` and `last_error` were written on every sync and read by nothing. The staleness ratchet is `last_synced_at` alone — a failed enumeration writes nothing at all, and the timestamp not advancing *is* the record — so the failure counter measured something no decision consulted, and the error string duplicated a log line. The display name was speculation about a UI that does not exist. Removing them takes `recordGroupFailure` with them: the failure path now writes to the database not at all, which is both simpler and a stronger statement of the invariant it was there to protect. * feat(knowledge): mirror Confluence space permissions and page restrictions Confluence joins Drive as a source an administrator crawl can mirror, and it is the case that shaped the contract. A page's restrictions come back only when that page is asked for, so they cannot ride along with the listing the way Drive's permissions do. `getDocumentAcls` resolves them for the whole listing at once, after it — round trips bounded by the corpus rather than by the page size, with the space's principals, each page's restriction, and every address resolved once per run and reused. Only an unrestricted page pays for its ancestry, which is the expensive lookup. A restriction replaces the space's permissions rather than narrowing them. Real Confluence access is the intersection, so this over-grants in exactly one case: somebody named on a page restriction who cannot view the space at all. That is a misconfiguration in the source, it errs toward a page they were deliberately named on, and it is what Onyx does. Representing the true intersection would mean expanding both principal sets to member addresses, which our group tables could do — but it emits one token per member, and a five-thousand-person space would carry five-thousand-token ACLs on every restricted page. `null` and `[]` are different answers throughout: no restriction means inherit from the nearest restricted ancestor, then the space; a restriction naming nobody means readable by nobody. Confluence itself only ever produces the first, but collapsing them would publish every deliberately locked page. One departure from the plan, which said to identify groups by name as Onyx does. Onyx uses names because its membership sync is keyed by name; ours is keyed by whatever the permissions API returns, and that is the id. Using it costs no lookup per group and survives a rename, which a name-keyed ACL would not. Directory enumeration moves behind one connector hook. The tenant is baked into every stored group token, and only the connector knows what a tenant is for its source — a Workspace domain for Drive, a site's cloud id for Confluence. The engine previously derived it from the impersonation subject, which Confluence does not have: its service account authenticates with an API token and impersonates nobody, so directory refresh would have silently skipped. The limit worth knowing: Confluence Cloud withholds an address whose owner's profile hides it, and a person we cannot name cannot be granted access. Those grants are dropped and counted rather than guessed at, and a group with a withheld member is reported incomplete so it never replaces a stored membership with a subset. * feat(knowledge): refresh mirrored directories on their own clock Group membership decides who can read an already-indexed document, so it has to move independently of the corpus: someone leaving a group should lose access in minutes, not on whatever schedule their documents happen to be re-crawled on. Until now the only thing that refreshed a directory was the admin crawl itself, which made the five-minute interval a ceiling rather than a cadence — on a connector syncing daily, a revoked membership stood for a day, bounded only by the staleness ratchet. A scheduler now offers every admin-mode connector each tick and lets `syncExternalDirectoryGroups` decide whether its directory is actually due. The crawl keeps its own refresh, which is a floor rather than a duplicate: it is what guarantees a crawl never publishes grants against membership nobody has read. Connectors sharing a directory cost one refresh between them — the first brings it up to date and the rest skip on the interval gate. Two ticks overlapping on one directory would both enumerate and write the same rows, which is wasteful and never wrong, so it takes no lease to prevent; every write on this path is idempotent, and a lease would be new state to keep correct for no behavioural gain. Failure is contained per connector. One workspace whose credential lapsed must not stop the tick refreshing every other workspace's directory, and a test covers exactly that. The refresh moves out of the sync engine so both callers share it, and the Confluence connector drops a concurrency helper it should never have had — `mapWithConcurrency` already existed in `lib/core/utils`. * fix(knowledge): close two admin-mode gaps found in an architecture audit Two real bugs, then the duplication and drift a full re-read of the branch turned up. Administrator mode was unreachable for Confluence. Entering the mode required an impersonation subject on every connector, but a Confluence service account holds an API token that already speaks for the site and impersonates nobody, so the check refused every attempt. A subject is now required only of a connector whose auth declares a subject field, and a test pins the token-backed case. An incremental listing could not carry a revoked grant. A permission change moves no content — re-sharing a file does not touch its modified time in Drive, restricting a page does not touch its version in Confluence — so an incremental run listed only edited documents and the ACL pass refreshed only those. A grant revoked on an unchanged document stood until the next full sync happened to run, which is the over-grant direction. Administrator mode now always lists the whole corpus; content is still hydrated by hash, so unchanged documents are never re-fetched or re-embedded, and the cost is metadata pages only. Configuration validation minted without impersonation. The shared token resolver took the source config as optional, and the validate path did not pass it, so a Drive service account checked its configuration against an empty domain. The config is required now, and every caller — sync, validation, mode switch, creation, and the directory scheduler — passes it. The Workspace domain was derived in two places with a comment saying they must agree. It is one function now, beside the Google directory adapter, which moves from the knowledge library to the Drive connector where Confluence's equivalent already lives; provider-specific API clients belong with their connector, and the orchestration that calls them stays provider-agnostic. The Confluence connector memoised its cloud id in four separate copies, which became one. A group identifier is canonicalised once, in `canonicalGroupId`, by the crawl that writes a token and the directory sync that stores the membership it resolves against. Both already lower-cased by different routes; now they cannot drift. Confluence identifies groups by id, so the docs claiming "never an opaque id" were wrong and are corrected in the token vocabulary and the schema. Removed what nothing read: the impersonation subject the token resolver returned, a `force` flag no caller passed, two vestigial type aliases, and a nesting-depth constant that had one consumer and now lives with it. The directory recency check is one aggregate query rather than two probes; the Confluence ACL resolver enriches principals once rather than once per page; the mode picker validates its value instead of casting it; and the admin-mode hint no longer describes a field only Drive has. The create path had two copies of the admin-role gate, one per permission-scoped mode, and has one. * fix(knowledge): read shared-drive permissions, and finish a pending switch before mirroring Two mechanics found by tracing the admin-mode path end to end rather than reading it. Every file on a shared drive was invisible. Drive does not populate a file's `permissions` when it lives on a shared drive — the docs say so outright, and the field must come from `permissions.list` instead. The listing left those files without an ACL, and the pass treated "no ACL" as "readable by nobody". A whole shared drive indexed to nothing is the failure the plan's own note about Onyx's `permissionIds` comment was warning about, and it was never built. The contract now says what it should have: `getDocumentAcls` is called with exactly the ids the listing could not answer for, and the engine merges the two sources with the listing's answer winning where it exists. Drive implements it by paging `permissions.list` per file under bounded concurrency; a file whose inline entries were incomplete goes the same way instead of being hidden. Confluence carries nothing inline, so it is unchanged. The merge is a pure function with its own tests, and the shared-drive tests fail if the hook is removed. A switch into administrator mode whose hide outgrew its request budget was never finished. The completion write cleared `accessRewritePending`, but the only thing the content engine did with the flag was restore workspace ACLs, which admin mode's SQL guard correctly ignored — so documents still carrying `{ws}` from before the switch kept it until the pass overwrote them, and any the listing missed kept it indefinitely. The pending hide now runs under the lease before the pass writes real ACLs, mirroring how the member engine finishes its own; the flag is then cleared on the strength of that. The shared-drive group token is gone. Nothing resolved it — the directory sync enumerates groups, not drives — and Drive already reports a drive's members as ordinary inherited permissions on each file, so the token was both redundant and a grant nobody could hold. * docs(knowledge): state why the ACL ceiling exists rather than where the number came from The comment justified 5,000 tokens by appeal to a reference implementation. The real reason is that the ceiling is a bug detector: with group tokens a legitimate document names tens of principals, so thousands means a connector expanded a group to its members — the exact failure group tokens prevent, and one that bloats the GIN index for every other document in the workspace. * chore(knowledge): register the directory-sync cron, and collapse the two migrations into one The directory-sync scheduler is now in the Helm cron map beside member-sync, and in the self-hosting background-jobs table. The Helm templates iterate the map, so the values entry is the whole change. The unique address index and the directory-group tables ship as one migration. The tables and the duplicate pre-check run inside the runner's batch transaction; the embedded COMMIT then lets the index on the hot `user` table build concurrently. A failure after that COMMIT replays the whole file against tables that are already committed, so every earlier statement is idempotent. Replaying it on a real database exposed one defect on the way: drizzle's derived foreign-key name for the membership table is 71 characters, Postgres silently truncates identifiers at 63, and the replay guard looked the full name up in `pg_constraint` and never found it. Both foreign keys now carry explicit short names in the schema. The pre-COMMIT section replays cleanly twice in a rolled-back transaction with both guards resolving. * refactor(auth): one email fold, in SQL and TypeScript, and no reads of the dead column An address was folded five different ways across the codebase: three SQL spellings — `lower(x)`, `lower(trim(x))`, `lower(btrim(x))` — plus reads of `user.normalized_email`, plus inline `trim().toLowerCase()`. Only one of the SQL forms matches the expression the new index is built on, so the others were sequential scans of `user` wearing the costume of an indexed lookup. `normalized_email` turned out to be populated after all — for a fifth of accounts, by a signup plugin removed in June, using Gmail dot-and-tag stripping. That is the right function for deduplicating signups and the wrong one for identity: it merges addresses a mail provider may route to different people, and it stops at the day the plugin left. Every read of the column is gone. The column itself stays for one release, because Better Auth selects every schema column and dropping it while the previous release still serves would break sign-in; the drop is the follow-up, and the repo's drop audit will name the argless reads that must be fixed first. `foldedEmail` now lives in the schema beside the index that indexes it, so the predicate and the index are one expression by construction. Its TypeScript twin is `normalizeEmail` from `@sim/utils/string`, which the new access code now uses instead of inlining the fold. The index is no longer unique. Production holds thirteen addresses that collide once folded — real, verified, active accounts — so a unique build would fail the deploy by design. Access resolution refuses to bind an ambiguous address, which keeps either account from reading the other's documents until the pairs are merged and the index can be promoted. The directory-sync cron joins docker/crontab; the parity audit caught that Helm alone was not enough. * fix(knowledge): Drive field mask named a permission field that does not exist `permittedBy` is not a field of the Permission resource — inheritance lives in `permissionDetails` — and Drive answers an invalid field selection with 400. The mask was unconditional, so every `files.list` on this branch failed, in every access mode, and the suite passed only because every Drive call is mocked and the assertion checked a prefix of the mask. Caught by a reviewer checking the mask against the API reference. Also from that review: `groups.list?domain=` enumerates one domain, while a Workspace customer routinely owns several — a grant to a group on a secondary domain named a group the directory never listed, readable by nobody; it is `customer=my_customer` now. A deleted account's grant no longer mints a token for whoever is later provisioned with the recycled address. The Admin SDK calls share the Drive retry wrapper, so one transient error no longer stales a group. And the `permissionIds` count heuristic is gone: the field counts users only, so any file with a domain or public grant tripped a needless fallback; absence of `permissions` is the one signal that matters, and it is kept. * fix(knowledge): close the audit findings on administrator access Admin mode was unreachable end to end: the queue still refused every mode but workspace, and neither modal ever sent the mode, so editing an admin connector saved it as workspace and published the corpus. A switch into admin mode also flipped before hiding, showing workspace documents under a mode whose reader expects source ACLs. - queue: dispatch every content-engine mode; the modals send the chosen mode; the access field offers administrator mode on availability, not on credential-group support - switch: hide before flipping into a mode that hides on entry; the engine finishes a pending hide before it lists, strips listing caps, and hides owned documents the listing did not name - Drive: domain shares resolve through a synthetic per-domain group whose one member is a wildcard the reader matches by their own domain; CUSTOMER members and every customer domain are covered - Confluence: each page falls back to its own space's readers, not the union across spaces; ancestors from the v2 collection the API still serves; space-role assignments expanded; pagination follows next links; per-page containment; retry on every call - directory sync: one background job per directory instead of a full walk inside the scheduler request; runnable statuses only; freshness keyed off the latest confirmed group so one unreadable group cannot keep a directory due forever - config edits on an admin connector re-assert the administrator subject; a connector whose ACL is derived cannot leave its documents behind - the access-mode enum lives in the leaf module and the contract derives from it; the mirror assertion moves to a leaf; the remaining ad-hoc email folds use foldedEmail; the impersonated subject leaves the logs * refactor(knowledge): tighten administrator access after the mechanics review - directory refresh idempotency keys on the sync interval, so a cron wrapper retry cannot start a second walk of the same directory; the scheduler offers every admin connector and lets the tenant-level freshness check dedupe, since a connector type does not imply one tenant - config validation seeds the run context from the token, so a Confluence service account validates without a discovery call it cannot make - the domain-share vocabulary (group id, wildcard member, domain fold) lives in one module; the Google directory drains pages through the shared Google pagination helper; Confluence's getJson owns the not-found branch - access-mode predicates take the union, and the engine narrows the locked row once; runnable statuses are one constant everywhere - a knowledge base with a mirrored connector reports hasPermissionScopedConnector - dead export, unused import, and stale docblocks removed * refactor(knowledge): collapse the access-mode vocabulary and the duplication around it /simplify and /cleanup over the branch. The mode literal 'admin' appeared in eight modules deciding four different things; it is now one leaf predicate each site reads. - access-modes owns MIRRORING_ACCESS_MODES/mirrorsSourceAcls and aclIsDerived; the identity mapper, the second name for the same union, and the duplicate credential-backed predicate are gone, and the contract derives its enum from the leaf - listing caps are stripped once per rule rather than at three depths: create, the mode switch, and a config edit all key off aclIsDerived, so the stored config agrees with what runs - the mirroring assertion moved into the source-config validator the application layer already owns, so orchestration stays mode-agnostic - one resolver for a connector's token user, shared by the engine and the directory refresh, replacing two copies that had already drifted - Drive asks for permissions only on a run the engine says mirrors, drains its permission pages through the shared Google helper, and spreads the ACL context instead of relisting its fields - Confluence memoises its three per-run lookups through one helper, keys pages by one map, drops the unreachable space-principal branch, and shares the cursor parser with the content listing - nested Drive subgroups are read once per directory rather than once per parent; the scheduler dispatches with bounded concurrency; batch loops use chunkArray Behaviour is unchanged except where the reviews found it wrong: a config edit on a mirroring connector now strips caps and re-asserts the administrator, and a crawl that is not mirroring no longer pulls a permission array per file. * feat(search): unify source access and scale indexing * fix(ci): align search cleanup checks and chart version * fix(search): clean up source access and directory refresh * improvement(search): remove Answer with Sim action * improvement(search): simplify source setup and preserve account handoffs * feat(assistant): use canonical search and personal integration accounts * feat(assistant): connect personal accounts in chat and reuse desktop tools * fix(slack): keep channel listing independent of credential storage * improvement(assistant): streamline account checks and bound catalog caching * improvement(search): unify source setup and member connection actions * improvement(search): clarify setup requirements and focus account connections * feat(search): add Gmail, Jira, GitHub and Calendar sources * feat(search): document connector setup and harden source access * feat(org): organization surface, app entry, and shared rail chrome Adds the organization view at /o/[organizationId] — the viewer's own view of one organization outside any workspace — and makes it the default landing for anyone who belongs to an organization. Organization surface - Membership-gated layout with an explicit denial for non-members - Sidebar mirroring the workspace rail: org header (empty menu for now), Search and Collapse, Home / Integrations / Skills / Workspaces tabs, Chats section, profile + help footer; the Workspaces tab grows a rail flyout when collapsed - Empty pages for home, integrations, skills, workspaces, and chat/[chatId] App entry - New /home route resolves on the server to the organization home or the workspace picker; /o resolves the same way - Every default post-auth destination now points at APP_ENTRY_PATH via lib/navigation/paths (proxy, login/signup/SSO/verify, OAuth callbacks, billing return URLs, invites, impersonation, emails, desktop start route) Shared chrome - WorkspaceChrome takes its sidebar as a prop and exposes collapse/peek state through a context, so both surfaces share one shell - Flat hairline divider between rail and pane; no gutter, no radius, and no layout transitions on collapse, fullscreen, or disclosure - emcn useScrollEdges + scrollFadeClass: one scroll-edge fade that only fires while content is hidden past an edge; dividers move to the neighboring block - IdentityTile replaces every workspace/org avatar; no per-entity color in the product (the DB column stays), Workspaces icon drawn in the house style - Sidebar default width 256px, capped at 400px; SidebarTooltip, isNavItemActive, getWorkspaceInitial, and the help URLs extracted to shared modules * fix(search): support GitHub App grants and verify connector ACLs * fix(search): deny revoked Atlassian grants and bound provider requests * test(search): verify unchanged document indexing recovery * fix(search): harden connector recovery and file lifecycle * feat(org): home composer, page shell, and completion blue Organization home gets the workspace chat's empty state, copied rather than imported: the greeting, the composer with its resource, attachment, skill, voice, and send controls, and a "Get started" list in the suggested-actions chrome. Each step carries a ring that fills with a check once done; "Create a workspace" reads the organization's real workspace list, the rest wait on their signals. Integrations, Skills, and Workspaces render through one OrganizationPage shell: the workspace's top header bar, a fixed title/description/tabs/ search/action header in the home reading column, and a scroll region with the sidebar's edge fade and padding. The active tab and the search text live in the URL (`tab`, `q`) and are read back through useOrganizationPageFilters, so a tab switch keeps the criteria and the open field. Search expands leftward into a ChipInput with the canonical quiet icon close button. The emcn scroll-edge fade gains a horizontal axis (useScrollEdges `axis: 'x'`, scrollFadeXClass) so the tab row scrolls under a fade when it meets the controls beside it. `--brand-blue` joins the brand tokens as the one blue for progress and completion states. * feat(search): integrate organization sources and private assistant * fix(org): clarify membership navigation and role-aware settings * fix(ui): align organization and workspace sidebar interactions * feat(org): organization surface UI, unified settings, and Sim Search setup Home returns to the workspace chat's empty state, wired to the Assistant: the greeting, the framed composer with the shared animated placeholder, and a Get started list whose steps read completion from real state. Search is its own tab, a home-style greeting over a pill field that docks to the page head on submit with results scrolling beneath; a failed search shows one quiet line and Try again rather than the server's text. The Workspaces page is gone; the list lives in the sidebar above Chats as rail chips with the sidebar's See more paging and the collapsed rail flyout. The header dropdown is the organization card — mark, name, member count — over a Settings row; the workspace header loses its organizations list. Organization settings become one surface: Account (General, the organization's Subscription), Organization, Governance, and Sim Search, with account sections rendered by the account plane's own renderer. The settings Integrations section, admin-only, hosts every Sim Search source with Set up, Manage, and a confirm-gated approval toggle; the Integrations page shows every member the same list with only their own actions. The setup's OAuth detours return to the settings section through one helper. Sim Search in Slack is an outbound row in the shared settings sidebar. The org layout seeds the viewer's profile through a shared prefetch, so the footer paints hydrated and a page hydrating the same key no longer mismatches. Loading pages are removed; content lands as it arrives. * feat(search): enforce organization integration approvals Persist admin approval independently of integration setup, wire the existing settings controls, and allow members to connect approved integrations. Enforce approval through shared search access checks and preserve indexed data for reapproval. Fix organization scope handling in member sync and connector sync analytics. Include migration and coverage for approval authorization, member setup, and PostgreSQL access predicates. * feat(connected-accounts): manage shared accounts at organization scope (#7586) * feat(connected-accounts): manage shared accounts at organization scope * fix(connected-accounts): handle state upgrades and align regression coverage * fix(sidebar): align workspace switcher top spacing * fix(connected-accounts): align organization settings and invitation flows * chore(db): remove Drizzle Kit dependency patch * fix(search): align operation registry coverage with integration approvals --------- Co-authored-by: Waleed Latif <walif6@gmail.com> * fix(search): harden organization setup and document access --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu> Co-authored-by: Theodore Li <theo@sim.ai>
1 parent ebf85f3 commit 3a23820

1,330 files changed

Lines changed: 169115 additions & 16706 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/emcn-components.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
3232
- **`ChipDatePicker`** — chip-styled date field.
3333
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
3434
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
35+
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
3536
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
3637

3738
## Modal keyboard defaults

.claude/rules/sim-styling.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcu
6060

6161
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
6262

63+
## Scroll Edges
64+
65+
A scroll region that can hide rows past an edge uses `useScrollEdges` with `scrollFadeClass` + `scrollFadeAttributes` from `@sim/emcn`: a 12px fade at an edge only while content is hidden beyond it, never at rest. The region's baseline padding lives on the scroll box itself (so rows pass through it under the fade), and the divider at that edge is drawn by the neighboring block, conditional on the same edge. Never hand-roll a `mask-image` gradient or a `scrollTop > 0` effect for this.
66+
6367
## Font Weight
6468

6569
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
@@ -70,7 +74,7 @@ Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-we
7074

7175
## Color Tokens
7276

73-
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
77+
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; progress and completion (a checked step, a done state) `--brand-blue``--selection` stays the interactive highlight; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
7478

7579
### Line weight
7680

apps/desktop/e2e/smoke.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { _electron as electron, expect, test } from '@playwright/test'
1010
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
1111

1212
const PAGES: Record<string, string> = {
13-
'/workspace': `<!doctype html><html><head><title>Sim Fixture</title></head><body>
13+
'/home': `<!doctype html><html><head><title>Sim Fixture</title></head><body>
1414
<h1 id="app">fixture-app</h1>
1515
<button id="internal-blank" onclick="window.open('/workspace/two', '_blank')">internal</button>
1616
<button id="external-blank" onclick="window.open('https://docs.sim.ai/x', '_blank')">external</button>
@@ -82,7 +82,7 @@ test.describe('desktop shell smoke', () => {
8282
app = await launchApp(origin)
8383
const window = await app.firstWindow()
8484
await expect(window.locator('#app')).toHaveText('fixture-app')
85-
expect(window.url()).toBe(`${origin}/workspace`)
85+
expect(window.url()).toBe(`${origin}/home`)
8686
})
8787

8888
test('internal window.open creates an independent full Sim window', async () => {
@@ -150,7 +150,7 @@ test.describe('desktop shell smoke', () => {
150150
app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal)
151151
)
152152
.toEqual(['https://docs.sim.ai/navigation'])
153-
expect(window.url()).toBe(`${origin}/workspace`)
153+
expect(window.url()).toBe(`${origin}/home`)
154154
})
155155

156156
test('unreachable origin shows the bundled offline page', async () => {

apps/desktop/src/main/app-routes.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ describe('app routes', () => {
55
it('derives the new-chat route from the last workspace route', () => {
66
expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home')
77
expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home')
8-
expect(newChatRoute('/account')).toBe('/workspace')
9-
expect(newChatRoute(undefined)).toBe('/workspace')
10-
expect(newChatRoute('//evil.example')).toBe('/workspace')
8+
expect(newChatRoute('/account')).toBe('/home')
9+
expect(newChatRoute(undefined)).toBe('/home')
10+
expect(newChatRoute('//evil.example')).toBe('/home')
1111
})
1212

1313
it('derives the settings route from the last workspace route', () => {
1414
expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop')
15-
expect(settingsRoute('/account')).toBe('/workspace')
16-
expect(settingsRoute(undefined)).toBe('/workspace')
17-
expect(settingsRoute('//evil.example')).toBe('/workspace')
15+
expect(settingsRoute('/account')).toBe('/home')
16+
expect(settingsRoute(undefined)).toBe('/home')
17+
expect(settingsRoute('//evil.example')).toBe('/home')
1818
})
1919
})

apps/desktop/src/main/app-routes.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { isSafeInternalPath } from '@/main/config'
1010
* do with the tray, and the tray can be absent entirely.
1111
*/
1212

13+
/**
14+
* The web app's signed-in entry. It resolves to the organization the user belongs
15+
* to, or to their workspaces, so the shell never has to know which applies.
16+
*/
17+
export const APP_ENTRY_ROUTE = '/home'
18+
1319
/** Workspace id from the last visited route, or null when it carries none. */
1420
function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
1521
if (isSafeInternalPath(lastRoute)) {
@@ -23,19 +29,19 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
2329

2430
/**
2531
* Route for "New Chat": the home (chat) surface of the workspace the user was
26-
* last in, falling back to the workspace picker redirect when the last route
27-
* carries no workspace.
32+
* last in, falling back to the app entry when the last route carries no
33+
* workspace.
2834
*/
2935
export function newChatRoute(lastRoute: string | undefined): string {
3036
const workspaceId = workspaceIdFromRoute(lastRoute)
31-
return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace'
37+
return workspaceId ? `/workspace/${workspaceId}/home` : APP_ENTRY_ROUTE
3238
}
3339

3440
/**
3541
* Route for "Settings…": the Sim app's settings surface for the workspace the
36-
* user was last in, falling back to the workspace picker redirect.
42+
* user was last in, falling back to the app entry.
3743
*/
3844
export function settingsRoute(lastRoute: string | undefined): string {
3945
const workspaceId = workspaceIdFromRoute(lastRoute)
40-
return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace'
46+
return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE
4147
}

apps/desktop/src/main/session-lifecycle.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ describe('decideStartRoute', () => {
7575
})
7676

7777
it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => {
78-
expect(decideStartRoute(undefined)).toBe('/workspace')
79-
expect(decideStartRoute('//evil.example')).toBe('/workspace')
80-
expect(decideStartRoute('/login')).toBe('/workspace')
78+
expect(decideStartRoute(undefined)).toBe('/home')
79+
expect(decideStartRoute('//evil.example')).toBe('/home')
80+
expect(decideStartRoute('/login')).toBe('/home')
8181
})
8282
})
8383

@@ -94,11 +94,11 @@ describe('resolveStartRoute', () => {
9494
)
9595
})
9696

97-
it('falls back to the workspace picker after confirmed access denial', async () => {
97+
it('falls back to the app entry after confirmed access denial', async () => {
9898
const session = sessionWithResponse(403, { error: 'Workspace access denied' })
9999

100100
await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe(
101-
'/workspace'
101+
'/home'
102102
)
103103
})
104104

apps/desktop/src/main/session-lifecycle.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
completeAccountDataTeardown,
88
waitForAccountDataMutations,
99
} from '@/main/account-data-generation'
10+
import { APP_ENTRY_ROUTE } from '@/main/app-routes'
1011
import { isSafeInternalPath } from '@/main/config'
1112
import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation'
1213
import type { EventRecorder } from '@/main/observability'
@@ -60,14 +61,14 @@ export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean {
6061

6162
/**
6263
* Picks the route to load at launch: the last visited route (when safe and
63-
* not itself an auth surface), falling back to /workspace. A signed-out
64+
* not itself an auth surface), falling back to the app entry. A signed-out
6465
* partition is handled by the web app's own login redirect.
6566
*/
6667
export function decideStartRoute(lastRoute: string | undefined): string {
6768
if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) {
6869
return lastRoute
6970
}
70-
return '/workspace'
71+
return APP_ENTRY_ROUTE
7172
}
7273

7374
function workspaceIdFromRoute(route: string): string | null {
@@ -110,8 +111,8 @@ export async function resolveStartRoute(
110111
}
111112
)
112113
if (response.status === 403) {
113-
logger.info('Saved workspace route is no longer accessible; opening workspace picker')
114-
return '/workspace'
114+
logger.info('Saved workspace route is no longer accessible; opening the app entry')
115+
return APP_ENTRY_ROUTE
115116
}
116117
return route
117118
} catch {

apps/docs/content/docs/cli/credentials.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Update Credential (OAuth login or personal API key required)
103103
| `--service-account-json <value>` | No | Write-only Google service-account JSON key. |
104104
| `--api-token <value>` | No | Write-only provider API token. |
105105
| `--domain <value>` | No | Provider account domain. |
106+
| `--atlassian-product <value>` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
106107
| `--signing-secret <value>` | No | Write-only webhook signing secret. |
107108
| `--bot-token <value>` | No | Write-only bot token. |
108109
| `--client-id <value>` | No | OAuth client identifier. |

apps/docs/content/docs/cli/reference.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ sim credentials update <credentialId> [options]
467467
| `--service-account-json <value>` | No | Write-only Google service-account JSON key. |
468468
| `--api-token <value>` | No | Write-only provider API token. |
469469
| `--domain <value>` | No | Provider account domain. |
470+
| `--atlassian-product <value>` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
470471
| `--signing-secret <value>` | No | Write-only webhook signing secret. |
471472
| `--bot-token <value>` | No | Write-only bot token. |
472473
| `--client-id <value>` | No | OAuth client identifier. |

apps/docs/content/docs/integrations/slack.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -956,15 +956,15 @@ Rename the Slack agent session associated with a thread.
956956

957957
### Slack List Channels
958958

959-
List up to 10,000 accessible Slack conversations across as many cursor pages as Slack supplies, capped at 200 provider pages. Credential-group user tokens also return one-to-one and group direct messages.
959+
List up to 10,000 accessible public and private Slack channels across as many cursor pages as Slack supplies, capped at 200 provider pages.
960960

961961
#### Input
962962

963963
| Parameter | Type | Required | Description |
964964
| --------- | ---- | -------- | ----------- |
965965
| `authMethod` | string | No | Authentication method: oauth or bot_token |
966966
| `botToken` | string | No | Bot token for Custom Bot |
967-
| `includePrivate` | boolean | No | Include private channels the bot is a member of \(default: true\) |
967+
| `includePrivate` | boolean | No | Include private channels the connected account can access \(default: true\) |
968968
| `excludeArchived` | boolean | No | Exclude archived channels \(default: true\) |
969969
| `limit` | number | No | Conversations to request per Slack page \(default: 100, max: 200\) |
970970
| `cursor` | string | No | Pagination cursor from a previous response.nextCursor to resume from |
@@ -974,7 +974,7 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as
974974

975975
| Parameter | Type | Description |
976976
| --------- | ---- | ----------- |
977-
| `channels` | array | Up to 10,000 accessible public and private channels, plus direct and group DMs for credential-group user tokens |
977+
| `channels` | array | Up to 10,000 accessible public and private channels |
978978
|`id` | string | Conversation ID \(for example, C123, D123, or G123\) |
979979
|`name` | string | Channel or group-DM name; omitted for one-to-one direct messages |
980980
|`is_channel` | boolean | Whether this is a channel |
@@ -998,8 +998,8 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as
998998
|`is_user_deleted` | boolean | Whether the other participant in a direct message is deactivated |
999999
|`is_open` | boolean | Whether a direct or group-direct-message conversation is open |
10001000
|`priority` | number | Slack sidebar sort priority |
1001-
| `ids` | array | Conversation IDs for every returned channel or DM |
1002-
| `names` | array | Names of returned channels and group DMs; one-to-one DMs have no name |
1001+
| `ids` | array | Conversation IDs for every returned channel |
1002+
| `names` | array | Names of returned channels |
10031003
| `count` | number | Total number of conversations returned across all fetched pages, up to 10,000 |
10041004
| `hasMore` | boolean | Whether more Slack conversation pages remain beyond the fetched window |
10051005
| `nextCursor` | string | Cursor to fetch the next page; null when there are no more pages |

0 commit comments

Comments
 (0)