mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Merge latest master and advance GitHub setup migration
This commit is contained in:
commit
e3d8a0a47b
102 files changed
+12172
-4560
No files matched your search
+3
-3
@@ -226,15 +226,15 @@ When authoring migrations or one-time backfills:
|
||||
- Split schema changes, index creation, and data backfill into separate phases so each step has clear locking and rollback behavior.
|
||||
- Treat the `check:migrations` CI gate as the enforcement backstop for these rules. If it flags a migration, rewrite the migration or add a suppression comment with the indexed predicate, batch bound, and reason the remaining scan is safe.
|
||||
|
||||
Private-task migration `0313` is explicitly allowlisted in the
|
||||
Paperclip executor to run outside a file-wide transaction. Its idempotent
|
||||
Private-task migrations `0313` and `0314` are explicitly allowlisted in the
|
||||
Paperclip executor to run outside a file-wide transaction. Their idempotent
|
||||
keyset batches commit every 1,000 rows, and migration history is recorded only
|
||||
when all batches finish. The executor repairs invalid concurrent indexes on
|
||||
retry. A reserved connection holds a session advisory lock across all batch
|
||||
commits, so concurrent migrators recheck history only after the preceding runner
|
||||
finishes. Privacy triggers are replaced atomically. Bootstrap uses the same
|
||||
executor; other migrations remain transactional
|
||||
per file. Apply this migration through `pnpm db:migrate` using a direct
|
||||
per file. Apply these migrations through `pnpm db:migrate` using a direct
|
||||
connection, before enabling the new server and UI.
|
||||
|
||||
## Migration snapshots
|
||||
|
||||
@@ -749,6 +749,36 @@ PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
||||
|
||||
No Docker or external database is required for this mode.
|
||||
|
||||
## Issue Privacy Rollout
|
||||
|
||||
`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the canonical opt-in issue/project privacy predicate:
|
||||
|
||||
- `enforce` (default): return private issues only to implicit principals, issue grantees, or private-project access members; hide private projects from non-members
|
||||
- `shadow`: log structured would-deny decisions but preserve existing reads; use only for rollout diagnosis
|
||||
- `off`: skip the predicate and shadow logging
|
||||
|
||||
Grant checks read current database state; there is no positive privacy cache.
|
||||
Sharing a task grants access to that task and its private descendants. Parents
|
||||
and siblings need separate grants. An agent's access is intersected with its
|
||||
run's responsible user's current access.
|
||||
|
||||
`enforce` is the production default. `shadow` and `off` disclose private data and
|
||||
are only temporary diagnostic overrides. The old branch's historical sign-off
|
||||
does not qualify the current runtime. See [ISSUE-PRIVACY.md](./ISSUE-PRIVACY.md)
|
||||
for the model, validation coverage, break-glass behavior, and residual risks.
|
||||
|
||||
Issue-bound heartbeat runs inherit the same read predicate through the stored
|
||||
`heartbeat_runs.scope_kind = 'issue'` and `issue_id` binding. Issue deletion
|
||||
nulls the foreign key but preserves the explicit issue scope as a fail-closed
|
||||
tombstone. Workspace-operation history is either deleted with its direct issue
|
||||
or run binding, or remains bound to that fail-closed run tombstone, so it cannot
|
||||
become company-level maintenance data through foreign-key nulling. In enforce mode, direct run detail, transcript,
|
||||
event, log, and workspace-operation reads return `404` to non-members. Company
|
||||
run-history and live-run lists retain a metadata-only row with timing, status,
|
||||
token usage, and cost for budget oversight; issue identifiers, summaries, and
|
||||
run content are omitted. Runs without an issue binding keep company-level
|
||||
maintenance-run visibility.
|
||||
|
||||
## Storage in Dev (Auto-Handled)
|
||||
|
||||
For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at:
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Private tasks and projects
|
||||
|
||||
Paperclip work is company-open by default. A task or project can instead be
|
||||
marked private when its title, discussion, documents, attachments, work
|
||||
products, or agent run traces should be limited to named participants.
|
||||
|
||||
## Who can read private work
|
||||
|
||||
A private task is readable by its responsible user, creating user, current
|
||||
user or agent assignee, active task grantees, and members of its private
|
||||
project. Restrictions flow down through children and run-created handoffs. Sharing or
|
||||
assigning a task grants access to that task and its descendants; ancestors and
|
||||
siblings require a separate grant. An assignment grant stays
|
||||
active until it is explicitly revoked. Current assignment, ownership, private-project
|
||||
membership, and inherited access remain independent reasons for access. The
|
||||
sharing panel shows the source; revoking one grant does not remove other reasons.
|
||||
Making a parent private protects existing descendants in the same transaction.
|
||||
Moving a task under a private parent inherits privacy. A child cannot be made
|
||||
public while it inherits a private boundary; making a parent public leaves its
|
||||
existing private children private.
|
||||
|
||||
Private projects have a separate access-member list. A task-level grant can
|
||||
expose one task without exposing its containing private project. Direct reads
|
||||
by non-members return `404`, and list, count, search, attention, status-card,
|
||||
activity, run-history, and tree-control surfaces apply the same predicate.
|
||||
Visible blocker and mention edges may show a locked identifier-only stub; they
|
||||
never include the private task's title or content.
|
||||
|
||||
Audit logs remain company-wide because they are the sanctioned oversight path.
|
||||
They contain entity identifiers rather than private task content. Dashboard and
|
||||
sidebar aggregate counts also remain company-wide: they may include private
|
||||
work in totals, but do not expose titles, descriptions, or identifiers. The
|
||||
issue list/count APIs themselves are viewer-filtered.
|
||||
|
||||
## Audited break-glass
|
||||
|
||||
Company owners and admins do not silently inherit private-task access. A normal
|
||||
task detail request still returns `404`. When an owner or admin has a legitimate
|
||||
emergency need, they must deliberately request the task with
|
||||
`?breakGlass=true`.
|
||||
|
||||
Every successful break-glass read writes both:
|
||||
|
||||
- an `issue.break_glass_read` audit row containing the actor, task id, and time;
|
||||
- a warning system notice on the private task so its owners can see that access occurred.
|
||||
|
||||
The flag does not change the canonical read predicate, create a grant, or make
|
||||
future reads implicit. Non-admin members and agents cannot use it.
|
||||
|
||||
Cloud customer-success inspection is a separate operator capability. When an
|
||||
operator enables it, the dedicated Cloud-signed permit can read private task
|
||||
content within its company scope. Ordinary users, agent API keys, and plugins
|
||||
cannot use that permit. Cloud enforces the inspection grant, expiration,
|
||||
revocation, replay protection, and audit trail; this path does not create a task
|
||||
grant or a task break-glass notice. Operators who require task ACLs on every
|
||||
content read must leave `PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED` disabled.
|
||||
See [Cloud customer-success inspection support](CUSTOMER-SUCCESS-INSPECTION.md).
|
||||
|
||||
## Agent runs and shared-agent residual risk
|
||||
|
||||
Issue-bound run detail, events, transcripts, logs, and workspace operations use
|
||||
the task ACL. Run authority is the intersection of the agent and the run's responsible user.
|
||||
Reusing an agent does not transfer a different user's private-task permission.
|
||||
Company run lists retain only timing/status/token/cost metadata for
|
||||
non-members so budget oversight continues without exposing task identity or run
|
||||
content.
|
||||
|
||||
Workspace operation records retain their original task and run sources even if
|
||||
those entities are deleted or the operation is relinked. Deletion preserves the
|
||||
audit record; unresolved sources make its details and log inaccessible. A
|
||||
company-scoped run does not override a private task linked to the operation.
|
||||
|
||||
> **Residual risk — trusted agents (`trust-agent`).** Paperclip enforces privacy
|
||||
> on reads from the control plane, but an agent that legitimately processes a
|
||||
> private task may write learned content into shared persistent memory, its home
|
||||
> directory, an external tool, or a later public response. V1 deliberately
|
||||
> trusts the agent not to exfiltrate that context. Use isolated execution
|
||||
> workspaces and appropriately trusted agents for sensitive work; task ACLs are
|
||||
> not a sandbox or data-loss-prevention system.
|
||||
|
||||
## Plugins and notification/digest surfaces
|
||||
|
||||
Plugins (including any Slack notifier or digest plugin) are **non-member
|
||||
principals**: they hold no company membership, task grant, or private-project
|
||||
access. Rather than an allowlist of individually patched methods, the plugin
|
||||
host centralizes the synthetic non-member check into shared helpers
|
||||
(`requirePluginReadableIssue`, `pluginReadableIssueIdsByIds`,
|
||||
`pluginRedactRelationSummary`) and routes **every** issue-derived read through
|
||||
them. The complete inventory of issue-derived reads and their disposition:
|
||||
|
||||
- **Direct issue content** — `issues.list` (SQL predicate), `issues.get`,
|
||||
`issues.listComments`, `issues.listAttachments`, `issues.getAttachmentContent`,
|
||||
`issueDocuments.list`, `issueDocuments.get`: a private task is filtered from
|
||||
list results, returns `null`/empty on soft reads, and reports "not found"
|
||||
(indistinguishable from missing) on document reads.
|
||||
- **Subtree / orchestration** — `issues.getSubtree` and
|
||||
`issues.getOrchestrationSummary`: a private *root* reports "not found"; an
|
||||
open root drops private descendants **before** any relation, document, run,
|
||||
or assignee is fetched, so no private row or its metadata enters the payload.
|
||||
- **Relationship metadata** — `issues.getRelations` (and the summaries echoed
|
||||
by `issues.setBlockedBy` / `addBlockers` / `removeBlockers`, and the subtree
|
||||
relation maps): blocker/blocks edges pointing at a private task are dropped,
|
||||
recursing into nested terminal blockers, so its title, status, and assignee
|
||||
never surface even on a public task.
|
||||
- **Interactions** — `issues.listInteractions`: a private task yields no
|
||||
interaction payloads.
|
||||
|
||||
A synthetic `{ type: "none" }` actor resolves to the same public-only scope as
|
||||
any other non-member. This is why a digest broadcast to a shared channel —
|
||||
whose audience is non-members by definition — cannot carry private issue
|
||||
content. Writes that operate on a task must also satisfy its read predicate; plugin
|
||||
capabilities do not substitute for task access. Returned relationship summaries
|
||||
remain redacted.
|
||||
|
||||
There is intentionally **no plugin oversight exception**: unlike the
|
||||
company-wide audit log, no plugin read path bypasses the predicate. A plugin
|
||||
that must surface private work would need an explicit, separately designed
|
||||
oversight capability; none exists today.
|
||||
|
||||
> **Residual metadata disclosure.** A plugin still learns aggregate,
|
||||
> content-free signals about private work that mirror the accepted
|
||||
> dashboard/count disclosure: `issues.getOrchestrationSummary` returns
|
||||
> company-wide `openBudgetIncidents`. Its `costs`/token roll-up is limited to
|
||||
> the readable subtree, and unreadable relationship edges are omitted entirely.
|
||||
> No private title, body, comment, attachment, identifier, relationship target,
|
||||
> or locked-edge signal is exposed.
|
||||
|
||||
## Rollout modes
|
||||
|
||||
`PAPERCLIP_ISSUE_PRIVACY_MODE=enforce` is the default. `shadow` records
|
||||
structured would-deny decisions without enforcing them and exists only for
|
||||
rollout diagnosis. `off` disables the task predicate. Operators should not use
|
||||
`shadow` or `off` when private-task confidentiality is required.
|
||||
|
||||
The production gate includes positive and negative checks: an authorized reader
|
||||
can read a shared child and its descendants, an unrelated reader cannot, and a
|
||||
warm HTTP or live subscription loses that grant after revocation. Privacy grants
|
||||
are read from the database without a cross-request positive cache.
|
||||
|
||||
## Migration and current runtime integration
|
||||
|
||||
Migration `0313_private_task_access.sql` adds the task/project ACL tables and
|
||||
backfills privacy parent edges with indexed keyset batches. Run binding is derived
|
||||
from native task identity, explicit issue identity, or legacy `issueId`/`taskId` context. Missing
|
||||
source tasks remain issue-scoped tombstones. Context changes cannot turn those
|
||||
runs into company-wide history. The migration is idempotent for preview installs;
|
||||
existing explicit grants are preserved. Operators upgrading an unreleased preview
|
||||
should inspect root grants created under its former whole-tree sharing semantics.
|
||||
|
||||
Migration `0314_private_task_draft_assets.sql` accompanies enforcement and binds
|
||||
historical inline images to their first owned task. Unbound drafts remain
|
||||
uploader-only under the new asset-content guard.
|
||||
|
||||
Both privacy migrations use the Paperclip migration executor's explicit
|
||||
nontransactional path. Indexes on existing tables build concurrently; invalid builds are repaired on
|
||||
retry. DDL statements commit individually and each 1,000-row
|
||||
keyset batch commits before advancing, releasing schema and row locks. The
|
||||
migration journal is written only after all batches succeed; an interruption
|
||||
leaves the migration pending and its idempotent statements can be replayed.
|
||||
Bootstrap uses the same executor, and other migrations retain a transaction per
|
||||
file. Apply these migrations with `pnpm db:migrate`, before enabling the new
|
||||
server or private-task UI.
|
||||
|
||||
Private output uses the same predicate on native tool searches and task context,
|
||||
linked approvals, training exports, execution workspace APIs, stored run-response
|
||||
assets, and WebSocket delivery. Workspace access requires permission for every
|
||||
linked task. Chat publications recheck recipient identity and task access at the
|
||||
provider boundary: private output is withheld from shared channels and from DMs
|
||||
whose known recipients no longer qualify. This does not revoke Gmail or other
|
||||
connection credentials; connection permissions continue to apply independently.
|
||||
|
||||
## CI leak-test inventory
|
||||
|
||||
The privacy regression gate is part of the normal server Vitest suite. Its
|
||||
surface coverage is intentionally distributed beside the routes and services
|
||||
it protects:
|
||||
|
||||
| Surface | Non-member regression coverage |
|
||||
| --- | --- |
|
||||
| Downward sharing, responsible-user intersection, mutation denial, provenance, live revocation | `privacy-production-review.test.ts` |
|
||||
| Task detail, list, count, grants, documents, work products | `issue-access-grants-routes.test.ts`, `company-search-service.test.ts` |
|
||||
| Search and machine extract | `company-search-service.test.ts`, `company-search-extract-service.test.ts` |
|
||||
| Attention feed | `attention-service.test.ts` |
|
||||
| Status-card hydrate and dry-run | `status-cards.test.ts` |
|
||||
| Activity stream | `activity-service.test.ts`, `activity-routes.test.ts` |
|
||||
| Run list, live run, detail, transcript, events, logs, operation history | `heartbeat-run-privacy-routes.test.ts` |
|
||||
| Tree holds and tree control | `issue-tree-control-routes.test.ts` |
|
||||
| Blocker and mention identifier-only stubs | `issue-access-grants-routes.test.ts` |
|
||||
| Attachment content | `issue-attachment-routes.test.ts` |
|
||||
| Private-project list and direct read | `projects-list-archived-routes.test.ts` |
|
||||
| Plugin issue reads (list, get, comments, attachments, orchestration, subtree, relations, interactions, documents) | `plugin-orchestration-apis.test.ts` |
|
||||
|
||||
Adding a new task-derived read surface requires a non-member fixture in this
|
||||
gate before the surface can ship.
|
||||
|
||||
Project privacy management belongs to its recorded creator (the responsible user
|
||||
for agent-created projects), the personal-project owner, and administrators.
|
||||
Project read membership alone never allows publishing the project or changing
|
||||
its audience. Legacy projects recover ownership from their creation audit event;
|
||||
when that evidence is missing, an administrator manages their privacy.
|
||||
|
||||
### Task privacy management hints
|
||||
|
||||
`GET /api/issues/:id/privacy-constraints` is available only to a principal who can read and manage that task. It returns the blocked scope kind and whether publishing leaves a personal project. It does not return protected parent or project names, identifiers, owners, or contents. This lets task owners manage their task without requiring access to its surrounding project. Visibility writes still enforce the canonical rules under the privacy-tree lock; these hints do not authorize a write.
|
||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Company model | Company is first-order; all business entities are company-scoped |
|
||||
| Board | Single human board operator per deployment |
|
||||
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
|
||||
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
|
||||
| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
@@ -220,6 +220,8 @@ Invariant: at least one root `company` level goal per company.
|
||||
- `goal_id` uuid fk `goals.id` null
|
||||
- `name` text not null
|
||||
- `description` text null
|
||||
- `visibility` enum-like text: `open | private`, default `open`
|
||||
- `personal_owner_user_id` text null; unique with `company_id` when present
|
||||
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
|
||||
- `lead_agent_id` uuid fk `agents.id` null
|
||||
- `target_date` date null
|
||||
@@ -258,6 +260,9 @@ retain their titles and default to no generation request.
|
||||
- `project_workspace_id` uuid fk `project_workspaces.id` null
|
||||
- `goal_id` uuid fk `goals.id` null
|
||||
- `parent_id` uuid fk `issues.id` null
|
||||
- `visibility` enum-like text: `open | private`, default `open`
|
||||
- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata
|
||||
- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs
|
||||
- `title` text not null
|
||||
- `description` text null
|
||||
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
|
||||
@@ -310,11 +315,39 @@ Invariants:
|
||||
- `author_user_id` uuid fk `users.id` null
|
||||
- `body` text not null
|
||||
|
||||
## 7.7.1 `issue_access_grants`
|
||||
|
||||
- `issue_id` uuid fk `issues.id` not null
|
||||
- `subject_type` enum-like text: `user | agent`
|
||||
- `subject_id` text not null
|
||||
- `source` enum-like text: `explicit | assignment | project`
|
||||
- `granted_by_user_id` text null
|
||||
- `granted_by_agent_id` uuid fk `agents.id` null
|
||||
- `created_at` timestamptz not null
|
||||
- `revoked_at` timestamptz null
|
||||
|
||||
An issue is readable when it is open outside a private project, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), the actor has an unrevoked grant on the issue or a private ancestor, or the actor is an access member of its private project. This is one canonical predicate shared by detail, list, count, search, and run-derived reads. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access. Sharing a child covers that child and descendants; ancestors and siblings require separate access. Agent reads also require the responsible user to qualify. A private parent protects existing and newly created descendants; making a parent public does not publish private descendants.
|
||||
|
||||
## 7.7.2 `project_access_members`
|
||||
|
||||
Authorization membership for private projects, distinct from the personal-sidebar `project_memberships` table:
|
||||
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk `companies.id` not null
|
||||
- `project_id` uuid fk `projects.id` not null
|
||||
- `subject_type` enum-like text: `user | agent`
|
||||
- `subject_id` text not null
|
||||
- `created_at` timestamptz not null
|
||||
- unique `(project_id, subject_type, subject_id)`
|
||||
|
||||
Private projects are absent from list and search results for non-members, and direct reads return `404`. An issue-level grant can still expose its issue without exposing the containing project. A user's `My private tasks` project is created lazily and concurrency-safely on their first unprojected private task; the owning user is its initial and non-removable project member. An agent-created private root also grants that creator issue-level access while retaining the responsible user as the human principal.
|
||||
|
||||
## 7.8 `heartbeat_runs`
|
||||
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `agent_id` uuid fk not null
|
||||
- `issue_id` uuid fk `issues.id` null; first-class binding for issue-scoped run confidentiality
|
||||
- `invocation_source` enum: `scheduler | manual | callback`
|
||||
- `status` enum: `queued | running | succeeded | failed | cancelled | timed_out`
|
||||
- `started_at` timestamptz null
|
||||
@@ -417,6 +450,7 @@ Operational policy:
|
||||
- `cost_events(company_id, occurred_at)`
|
||||
- `cost_events(company_id, agent_id, occurred_at)`
|
||||
- `heartbeat_runs(company_id, agent_id, started_at desc)`
|
||||
- `heartbeat_runs(company_id, issue_id, created_at)`
|
||||
- `approvals(company_id, status, type)`
|
||||
- `activity_log(company_id, created_at desc)`
|
||||
- `assets(company_id, created_at desc)`
|
||||
@@ -706,7 +740,7 @@ changes so both agent and board edits are visible in the issue activity stream.
|
||||
|
||||
## 9.4 Permission Terminology and Default Visibility Rule
|
||||
|
||||
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
|
||||
Paperclip keeps company-scoped visibility as the default. The task-ACL foundation adds opt-in private issues while preserving `open` for all existing and unspecified issues.
|
||||
|
||||
The approved term set is:
|
||||
|
||||
@@ -720,8 +754,8 @@ The approved term set is:
|
||||
## 9.5 Core V1 Rule: what “private” means
|
||||
|
||||
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
|
||||
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
|
||||
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
|
||||
- Work objects remain company-open by default. An issue or project can opt into the shared private-read predicate.
|
||||
- Organization-wide custom ACL policy remains deferred to Pro/Enterprise controls.
|
||||
|
||||
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
|
||||
|
||||
@@ -732,7 +766,7 @@ The approved term set is:
|
||||
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
|
||||
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
|
||||
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
|
||||
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
|
||||
| Work-object visibility | Company-open by default; opt-in private issues/projects with explicit user or agent access | Policy-driven ACLs and reviewer-only channels |
|
||||
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
|
||||
| Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX |
|
||||
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
|
||||
@@ -1139,6 +1173,9 @@ Invites tab does not hide this Cloud action.
|
||||
- `POST /companies/:companyId/issues`
|
||||
- `GET /issues/:issueId`
|
||||
- `PATCH /issues/:issueId`
|
||||
- `GET /issues/:issueId/access-grants`
|
||||
- `POST /issues/:issueId/access-grants`
|
||||
- `POST /issues/:issueId/access-grants/:grantId/revoke`
|
||||
- `GET /issues/:issueId/documents`
|
||||
- `GET /issues/:issueId/documents/:key`
|
||||
- `PUT /issues/:issueId/documents/:key`
|
||||
@@ -1198,6 +1235,11 @@ assignee/run ownership checks, and the `issue.released` activity event still app
|
||||
- `POST /companies/:companyId/projects`
|
||||
- `GET /projects/:projectId`
|
||||
- `PATCH /projects/:projectId`
|
||||
- `GET /projects/:projectId/access-members`
|
||||
- `POST /projects/:projectId/access-members`
|
||||
- `DELETE /projects/:projectId/access-members/:memberId`
|
||||
|
||||
Project list, search, direct reads, and nested project routes apply private-project access. Non-members receive the same not-found behavior as missing projects.
|
||||
|
||||
## 10.6 Current-user Resource Memberships
|
||||
|
||||
|
||||
+3
-1
@@ -178,7 +178,7 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
|
||||
|
||||
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
|
||||
|
||||
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
|
||||
Visibility settings on an agent profile do not control task privacy. Work is company-open by default; opt-in private tasks and projects use centralized authorization as specified in `SPEC-implementation.md` and `ISSUE-PRIVACY.md`. Sharing a child grants access to it and its descendants. Ancestors and siblings require separate access.
|
||||
|
||||
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
|
||||
|
||||
@@ -326,6 +326,8 @@ recipients must be valid and authorized in the company before a question is
|
||||
saved. See `SPEC-implementation.md` §9.8.1 for the resolver contract.
|
||||
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with the task's selected visibility. New execution tasks are ordinary project tasks, not structural children of the conversation; private-source restrictions still flow through their run provenance. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
||||
@@ -312,6 +312,43 @@ up where the last one left off, maintaining conversation context,
|
||||
memories, and tool state across heartbeats. The `sessionCodec` validates
|
||||
and migrates session state between runs.
|
||||
|
||||
### Managed instructions and resumed turns
|
||||
|
||||
The local adapter reads `instructionsFilePath` on every run. It sends that
|
||||
bundle, its relative-reference base directory, and the standard Paperclip API
|
||||
guidance through Hermes's native `HERMES_EPHEMERAL_SYSTEM_PROMPT` overlay.
|
||||
Hermes applies this overlay at each model request, including after context
|
||||
compression. It does not append the overlay to saved user turns. This requires
|
||||
a Hermes CLI that supports the [documented environment hook](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md).
|
||||
|
||||
Fresh sessions, resumed sessions, and session resets all receive the current
|
||||
bundle. Edits take effect on the next run; removing the bundle removes it from
|
||||
the next overlay. The server's session compatibility and conversation generation
|
||||
checks still control session reuse. A missing or failed resume remains a failed
|
||||
run and requires an explicit session reset; it does not automatically restart
|
||||
work. The next fresh run receives the complete current instruction overlay.
|
||||
|
||||
The `-q` user turn contains current runtime identity, task/wake context, handoff
|
||||
content, and the rendered custom `promptTemplate`, if configured. Custom templates
|
||||
run on every normal wake. `quiet` changes CLI display only. Disabling persistence
|
||||
uses full fresh-session context even if old session parameters are supplied.
|
||||
|
||||
An explicit `HERMES_EPHEMERAL_SYSTEM_PROMPT` in `env` is preserved before the
|
||||
Paperclip overlay. Hermes gives this environment hook precedence over its profile's
|
||||
configured personality/system prompt. Put required additional profile guidance in
|
||||
that explicit environment value or in the managed bundle. Paperclip does not write
|
||||
Hermes profile files or modify the parent process environment. Existing copies in
|
||||
old user turns remain in history until Hermes compresses them or the session resets.
|
||||
|
||||
Run the credential-free capture-process regression tests with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/server/execute.instructions.test.ts
|
||||
```
|
||||
|
||||
These tests verify actual child-process arguments and environment, not provider
|
||||
billing, model behavior, or measured token savings.
|
||||
|
||||
### Skills Integration
|
||||
|
||||
The adapter scans two skill sources and merges them:
|
||||
|
||||
@@ -114,6 +114,14 @@ tools, persistent memory, session persistence, skills, and MCP support.
|
||||
| env | object | {} | Extra environment variables |
|
||||
| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders |
|
||||
|
||||
Managed \`instructionsFilePath\` bundles and standard Paperclip API guidance use
|
||||
Hermes's native \`HERMES_EPHEMERAL_SYSTEM_PROMPT\` overlay on every run, including
|
||||
resume and reset. They are applied at request time and are not appended to user
|
||||
history. The query keeps current runtime identity, wake/task content, and custom
|
||||
templates. Use a Hermes CLI that supports this environment hook. An explicit
|
||||
overlay in \`env\` is preserved; the hook takes precedence over Hermes's configured
|
||||
profile personality/system prompt.
|
||||
|
||||
## Hermes-Originated Paperclip Tasks
|
||||
|
||||
This adapter package also ships a Hermes-facing Paperclip task bridge skill:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
import { sessionCodec } from "./index.js";
|
||||
|
||||
const SESSION_ID = "20261006_200000_abcdef";
|
||||
const INSTRUCTIONS = "Synthetic governance marker: request approval before governed actions.";
|
||||
|
||||
interface Invocation {
|
||||
args: string[];
|
||||
systemPrompt: string | null;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
describe("Hermes managed instruction delivery (real capture process)", () => {
|
||||
let dir: string;
|
||||
let command: string;
|
||||
let instructionsFilePath: string;
|
||||
let capturePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-instructions-"));
|
||||
command = path.join(dir, "hermes.cjs");
|
||||
instructionsFilePath = path.join(dir, "instructions.md");
|
||||
capturePath = path.join(dir, "invocations.jsonl");
|
||||
await fs.writeFile(instructionsFilePath, INSTRUCTIONS);
|
||||
await fs.writeFile(command, `#!${process.execPath}
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.CAPTURE_PATH, JSON.stringify({
|
||||
args,
|
||||
systemPrompt: process.env.HERMES_EPHEMERAL_SYSTEM_PROMPT ?? null,
|
||||
runId: process.env.PAPERCLIP_RUN_ID,
|
||||
}) + "\\n");
|
||||
if (process.env.FIXTURE_STDOUT) console.log(process.env.FIXTURE_STDOUT);
|
||||
if (args.includes("missing-session")) {
|
||||
console.error("\\u001b[1;31mSession missing-session not found.\\u001b[0m");
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.FIXTURE_PROVIDER_ERROR) {
|
||||
console.error(process.env.FIXTURE_PROVIDER_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Fixture completed\\nsession_id: ${SESSION_ID}");
|
||||
`, { mode: 0o700 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function context(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-fresh",
|
||||
agent: {
|
||||
id: "agent-synthetic", companyId: "company-synthetic", name: "Hermes fixture",
|
||||
adapterType: "hermes_local", adapterConfig: {},
|
||||
},
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task-synthetic" },
|
||||
config: {
|
||||
hermesCommand: command, provider: "openrouter", model: "fixture-model", cwd: dir,
|
||||
instructionsFilePath, env: { CAPTURE_PATH: capturePath }, timeoutSec: 10, graceSec: 1,
|
||||
},
|
||||
context: {
|
||||
issueId: "task-synthetic",
|
||||
paperclipTaskMarkdownAssignment: "Complete current synthetic task.",
|
||||
paperclipTaskMarkdownCompact: "Current synthetic task.",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "task-synthetic", title: "Synthetic task", status: "in_progress" },
|
||||
comments: [{ id: "comment-synthetic", body: "Fresh human direction." }],
|
||||
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function invocations(): Promise<Invocation[]> {
|
||||
return (await fs.readFile(capturePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function query(invocation: Invocation): string {
|
||||
return invocation.args[invocation.args.indexOf("-q") + 1];
|
||||
}
|
||||
|
||||
it.each([false, true])("keeps instructions out of fresh and resumed user turns (conversation=%s)", async (conversationMode) => {
|
||||
const fresh = context();
|
||||
fresh.context.conversationMode = conversationMode;
|
||||
const first = await execute(fresh);
|
||||
const params = sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null));
|
||||
expect(params?.sessionId).toBe(SESSION_ID);
|
||||
await execute({ ...fresh, runId: "run-resumed", runtime: { ...fresh.runtime, sessionParams: params } });
|
||||
|
||||
const [initial, resumed] = await invocations();
|
||||
expect(initial.args).not.toContain("--resume");
|
||||
expect(resumed.args.slice(resumed.args.indexOf("--resume"))).toEqual(["--resume", SESSION_ID]);
|
||||
for (const invocation of [initial, resumed]) {
|
||||
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
|
||||
expect(invocation.systemPrompt).toContain(`Resolve any relative file references from ${dir}/`);
|
||||
expect(query(invocation)).not.toContain(INSTRUCTIONS);
|
||||
expect(query(invocation)).not.toContain("Safe multiline update pattern:");
|
||||
expect(query(invocation)).toContain("Fresh human direction.");
|
||||
expect(query(invocation)).toContain(invocation.runId);
|
||||
expect(query(invocation)).toContain("company-synthetic");
|
||||
expect(invocation.systemPrompt).toContain("$PAPERCLIP_API_KEY");
|
||||
expect(invocation.systemPrompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
|
||||
expect(invocation.systemPrompt).toContain("body=$(cat <<'MD'");
|
||||
expect(invocation.systemPrompt).toContain("--data-binary @-");
|
||||
expect(invocation.systemPrompt).toContain('$api/issues/$PAPERCLIP_TASK_ID');
|
||||
expect(invocation.systemPrompt).not.toContain(invocation.runId);
|
||||
}
|
||||
});
|
||||
|
||||
it("reapplies current instructions after edits and a session reset", async () => {
|
||||
const ctx = context();
|
||||
await execute(ctx);
|
||||
await fs.writeFile(instructionsFilePath, "Updated governance marker.");
|
||||
await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
await execute({ ...ctx, runId: "run-reset" });
|
||||
const [, edited, reset] = await invocations();
|
||||
for (const invocation of [edited, reset]) {
|
||||
expect(invocation.systemPrompt).toContain("Updated governance marker.");
|
||||
expect(invocation.systemPrompt).not.toContain(INSTRUCTIONS);
|
||||
expect(query(invocation)).not.toContain("Updated governance marker.");
|
||||
}
|
||||
expect(reset.args).not.toContain("--resume");
|
||||
});
|
||||
|
||||
it("uses fresh wake context when persistence is disabled despite stored session params", async () => {
|
||||
const ctx = context();
|
||||
const result = await execute({ ...ctx, config: { ...ctx.config, persistSession: false }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
const [invocation] = await invocations();
|
||||
expect(invocation.args).not.toContain("--resume");
|
||||
expect(query(invocation)).toContain("## Paperclip Wake Payload");
|
||||
expect(query(invocation)).not.toContain("## Paperclip Resume Delta");
|
||||
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
|
||||
expect(result.sessionParams).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps custom templates and quiet mode dynamic while preserving a configured system overlay", async () => {
|
||||
const ctx = context();
|
||||
await execute({ ...ctx, config: {
|
||||
...ctx.config, quiet: true, promptTemplate: "Current custom direction for {{runId}}.",
|
||||
env: { CAPTURE_PATH: capturePath, HERMES_EPHEMERAL_SYSTEM_PROMPT: "User configured system guidance." },
|
||||
}, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
const [invocation] = await invocations();
|
||||
expect(invocation.args).toContain("-Q");
|
||||
expect(query(invocation)).toContain("Current custom direction for run-fresh.");
|
||||
expect(query(invocation)).not.toContain(INSTRUCTIONS);
|
||||
expect(invocation.systemPrompt).toContain("User configured system guidance.");
|
||||
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps an empty wake runnable with current identity (conversation=%s)", async (conversationMode) => {
|
||||
await execute(context({ context: { conversationMode } }));
|
||||
const [invocation] = await invocations();
|
||||
expect(query(invocation)).toContain("run-fresh");
|
||||
expect(query(invocation)).not.toContain(INSTRUCTIONS);
|
||||
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
|
||||
});
|
||||
|
||||
it("surfaces a missing session without automatically restarting work", async () => {
|
||||
const ctx = context();
|
||||
const result = await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: "missing-session" } } });
|
||||
const calls = await invocations();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].args).toContain("--resume");
|
||||
expect(calls[0].systemPrompt).toContain(INSTRUCTIONS);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.sessionParams).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retry provider failures as a missing session", async () => {
|
||||
const ctx = context();
|
||||
const result = await execute({ ...ctx, config: { ...ctx.config, env: { CAPTURE_PATH: capturePath, FIXTURE_PROVIDER_ERROR: "Error: provider unavailable" } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
expect(await invocations()).toHaveLength(1);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorMessage).toBe("Error: provider unavailable");
|
||||
});
|
||||
|
||||
it("preserves failed work even when its output contains a missing-session diagnostic", async () => {
|
||||
const ctx = context();
|
||||
const result = await execute({ ...ctx, config: { ...ctx.config, env: {
|
||||
CAPTURE_PATH: capturePath,
|
||||
FIXTURE_STDOUT: `Already updated the task.\nSession ${SESSION_ID} not found.`,
|
||||
FIXTURE_PROVIDER_ERROR: "Error: provider unavailable after tool execution",
|
||||
} }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
expect(await invocations()).toHaveLength(1);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.summary).toContain("Already updated the task.");
|
||||
expect(result.errorMessage).toBe("Error: provider unavailable after tool execution");
|
||||
});
|
||||
|
||||
it("removes an obsolete instruction overlay when the bundle is removed", async () => {
|
||||
const ctx = context();
|
||||
await execute(ctx);
|
||||
await execute({ ...ctx, config: { ...ctx.config, instructionsFilePath: undefined }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
|
||||
const [, resumed] = await invocations();
|
||||
expect(resumed.systemPrompt).not.toContain(INSTRUCTIONS);
|
||||
expect(resumed.systemPrompt).toContain("Paperclip API guidance:");
|
||||
expect(query(resumed)).toContain("Fresh human direction.");
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
selectPaperclipPromptSections,
|
||||
stringifyPaperclipWakePayload,
|
||||
@@ -82,7 +81,7 @@ export function resolveHermesCommand(config: Record<string, unknown>): string {
|
||||
// Wake-up prompt builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HERMES_DEFAULT_PROMPT_TEMPLATE = [
|
||||
const HERMES_RUNTIME_IDENTITY_TEMPLATE = [
|
||||
'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.',
|
||||
"",
|
||||
"Paperclip runtime identity:",
|
||||
@@ -90,7 +89,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
|
||||
"- Company ID: {{agent.companyId}}",
|
||||
"- Run ID: {{run.id}}",
|
||||
"- API base: {{paperclipApiUrl}}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// Hermes applies this overlay at each API call, including after compaction,
|
||||
// without storing it in conversation history. Keep run/task deltas in -q.
|
||||
const HERMES_SYSTEM_PROMPT_TEMPLATE = [
|
||||
"Paperclip API guidance:",
|
||||
"- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.",
|
||||
"- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.",
|
||||
@@ -112,7 +115,7 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
|
||||
"MD",
|
||||
")",
|
||||
"jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\",
|
||||
" curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\",
|
||||
" curl -sS -X PATCH \"$api/issues/$PAPERCLIP_TASK_ID\" \\",
|
||||
" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\",
|
||||
" -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\",
|
||||
" -H \"Content-Type: application/json\" \\",
|
||||
@@ -141,9 +144,7 @@ export function buildPrompt(
|
||||
options: { resumedSession?: boolean } = {},
|
||||
): string {
|
||||
const context = (ctx as any).context || {};
|
||||
const template = cfgString(config.promptTemplate) || (context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: HERMES_DEFAULT_PROMPT_TEMPLATE);
|
||||
const template = cfgString(config.promptTemplate);
|
||||
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
|
||||
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
|
||||
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
|
||||
@@ -200,10 +201,11 @@ export function buildPrompt(
|
||||
paperclipRunIdEnv: "PAPERCLIP_RUN_ID",
|
||||
};
|
||||
|
||||
const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
: renderTemplate(renderConditionalSections(template, vars), vars);
|
||||
const rendered = template && !isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? renderTemplate(renderConditionalSections(template, vars), vars)
|
||||
: "";
|
||||
return joinPromptSections([
|
||||
renderTemplate(HERMES_RUNTIME_IDENTITY_TEMPLATE, vars),
|
||||
wakePrompt,
|
||||
sessionHandoffMarkdown,
|
||||
taskContextMarkdown,
|
||||
@@ -404,7 +406,7 @@ export async function execute(
|
||||
|
||||
// ── Load agent instructions file (Paperclip instruction bundles) ──────
|
||||
// Paperclip can materialize managed instructions into instructionsFilePath;
|
||||
// when present, inject that bundle into the Hermes prompt.
|
||||
// when present, reapply that bundle through Hermes's native system overlay.
|
||||
const instructionsFilePath = cfgString(config.instructionsFilePath);
|
||||
let agentInstructions = "";
|
||||
if (instructionsFilePath) {
|
||||
@@ -430,10 +432,8 @@ export async function execute(
|
||||
}
|
||||
|
||||
// ── Build prompt ───────────────────────────────────────────────────────
|
||||
let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) });
|
||||
if (agentInstructions) {
|
||||
prompt = agentInstructions + "\n\n---\n\n" + prompt;
|
||||
}
|
||||
const sessionId = persistSession ? prevSessionId : undefined;
|
||||
const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) });
|
||||
|
||||
// ── Build command args ─────────────────────────────────────────────────
|
||||
// Use -Q (quiet) to get clean output: just response + session_id line
|
||||
@@ -474,13 +474,9 @@ export async function execute(
|
||||
// system is designed for human-attended interactive sessions.
|
||||
args.push("--yolo");
|
||||
|
||||
if (persistSession && prevSessionId) {
|
||||
args.push("--resume", prevSessionId);
|
||||
}
|
||||
|
||||
if (extraArgs?.length) {
|
||||
args.push(...extraArgs);
|
||||
}
|
||||
// Failed resumes remain failures. Do not infer a safe restart from CLI text.
|
||||
if (sessionId) args.push("--resume", sessionId);
|
||||
if (extraArgs?.length) args.push(...extraArgs);
|
||||
|
||||
// ── Build environment ──────────────────────────────────────────────────
|
||||
const userEnv = config.env as Record<string, string> | undefined;
|
||||
@@ -491,6 +487,17 @@ export async function execute(
|
||||
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
||||
};
|
||||
|
||||
// Scope the overlay to this child process. Read the current bundle on every
|
||||
// invocation; never infer its availability from a persisted session ID.
|
||||
// Preserve an operator's explicit overlay before Paperclip's instructions.
|
||||
env.HERMES_EPHEMERAL_SYSTEM_PROMPT = joinPromptSections([
|
||||
env.HERMES_EPHEMERAL_SYSTEM_PROMPT,
|
||||
agentInstructions,
|
||||
renderTemplate(HERMES_SYSTEM_PROMPT_TEMPLATE, {
|
||||
agent: ctx.agent,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||
|
||||
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
|
||||
@@ -527,10 +534,10 @@ export async function execute(
|
||||
"stdout",
|
||||
`[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`,
|
||||
);
|
||||
if (prevSessionId) {
|
||||
if (sessionId) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[hermes] Resuming session: ${prevSessionId}\n`,
|
||||
`[hermes] Resuming session: ${sessionId}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -234,19 +234,16 @@ test("keeps authoritative parent and ancestor context from task markdown", () =>
|
||||
expect(prompt).not.toContain("check the issue body or comments for references");
|
||||
});
|
||||
|
||||
test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => {
|
||||
test("keeps current runtime identity in the user turn without repeating static API examples", () => {
|
||||
const prompt = buildPrompt(baseContext(), {
|
||||
paperclipApiUrl: "http://paperclip.local/api",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`");
|
||||
expect(prompt).toContain("Displayed command logs may redact secrets");
|
||||
expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"');
|
||||
expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
|
||||
expect(prompt).toContain("body=$(cat <<'MD'");
|
||||
expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\"");
|
||||
expect(prompt).toContain("--data-binary @-");
|
||||
expect(prompt).not.toContain("Authorization: Bearer <");
|
||||
expect(prompt).toContain("- Agent ID: agent-1");
|
||||
expect(prompt).toContain("- Company ID: company-1");
|
||||
expect(prompt).toContain("- Run ID: run-1");
|
||||
expect(prompt).toContain("- API base: http://paperclip.local/api");
|
||||
expect(prompt).not.toContain("Safe multiline update pattern:");
|
||||
});
|
||||
|
||||
test("preserves custom prompt templates while exposing runtime and wake variables", () => {
|
||||
@@ -276,7 +273,7 @@ test("preserves custom prompt templates while exposing runtime and wake variable
|
||||
expect(prompt).toContain('"reason":"issue_assigned"');
|
||||
expect(prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```");
|
||||
expect(prompt).not.toContain("Paperclip runtime identity:");
|
||||
expect(prompt).toContain("Paperclip runtime identity:");
|
||||
});
|
||||
|
||||
test("keeps historical task markdown available to custom templates while automatic context uses assignment markdown", () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Runs through the Paperclip migration executor outside a file-wide transaction.
|
||||
-- Each idempotent keyset batch commits before advancing; history is recorded last.
|
||||
-- Bind historical inline draft images to their first owned task. Unbound drafts
|
||||
-- remain uploader-only. Attachment deletion never makes the draft company-open.
|
||||
DO $$
|
||||
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
|
||||
BEGIN
|
||||
LOOP
|
||||
SELECT max(id::text)::uuid INTO next_id FROM (
|
||||
SELECT id FROM assets WHERE id > cursor_id AND object_key LIKE company_id::text || '/assets/issues/drafts/%'
|
||||
ORDER BY id LIMIT 1000
|
||||
) batch;
|
||||
EXIT WHEN next_id IS NULL;
|
||||
INSERT INTO issue_attachments (company_id, issue_id, asset_id)
|
||||
SELECT a.company_id, first_issue.id, a.id FROM assets a
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT i.id FROM issues i WHERE i.company_id = a.company_id
|
||||
AND (i.created_by_user_id = a.created_by_user_id OR i.responsible_user_id = a.created_by_user_id
|
||||
OR i.created_by_agent_id = a.created_by_agent_id)
|
||||
AND strpos(coalesce(i.description, ''), '/api/assets/' || a.id::text || '/content') > 0
|
||||
ORDER BY i.created_at, i.id LIMIT 1
|
||||
) first_issue
|
||||
WHERE a.id > cursor_id AND a.id <= next_id AND a.object_key LIKE a.company_id::text || '/assets/issues/drafts/%'
|
||||
ON CONFLICT DO NOTHING;
|
||||
cursor_id := next_id;
|
||||
COMMIT;
|
||||
END LOOP;
|
||||
END $$;
|
||||
File renamed without changes.
+4143
-4168
File diff suppressed because it is too large.
Load diff
+2192
-14
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -2189,8 +2189,15 @@
|
||||
{
|
||||
"idx": 314,
|
||||
"version": "7",
|
||||
"when": 1791374848340,
|
||||
"tag": "0314_loving_ezekiel",
|
||||
"when": 1791371819805,
|
||||
"tag": "0314_private_task_draft_assets",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 315,
|
||||
"version": "7",
|
||||
"when": 1791375322650,
|
||||
"tag": "0315_talented_hercules",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { expect, it } from "vitest";
|
||||
import { applyPendingMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
||||
|
||||
itEmbeddedPostgres("backfills owned draft images without attaching another uploader's images", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-draft-privacy-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const migration = await fs.readFile(new URL("./migrations/0314_private_task_draft_assets.sql", import.meta.url), "utf8");
|
||||
const hash = createHash("sha256").update(migration).digest("hex");
|
||||
const company = randomUUID(), task = randomUUID(), owned = randomUUID(), foreign = randomUUID();
|
||||
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Draft privacy', 'DRAFT')`;
|
||||
for (const [id, owner] of [[owned, 'owner'], [foreign, 'someone-else']]) {
|
||||
await sql`INSERT INTO assets (id, company_id, provider, object_key, content_type, byte_size, sha256, created_by_user_id)
|
||||
VALUES (${id}, ${company}, 'local_disk', ${`${company}/assets/issues/drafts/${id}`}, 'image/png', 1, 'test', ${owner})`;
|
||||
}
|
||||
await sql`INSERT INTO issues (id, company_id, title, description, visibility, created_by_user_id)
|
||||
VALUES (${task}, ${company}, 'Private task', ${` `}, 'private', 'owner')`;
|
||||
for (let run = 0; run < 2; run += 1) {
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await sql`SELECT issue_id, asset_id FROM issue_attachments WHERE company_id = ${company}`)
|
||||
.toEqual([{ issue_id: task, asset_id: owned }]);
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
await database.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1105,6 +1105,7 @@ export type {
|
||||
CompactIssue,
|
||||
Issue,
|
||||
IssueVisibility,
|
||||
IssuePrivacyConstraints,
|
||||
IssueAccessGrant,
|
||||
IssueAccessGrantAgentVisibility,
|
||||
IssueAccessGrantSubjectType,
|
||||
@@ -1936,10 +1937,12 @@ export {
|
||||
projectDiscoverySchema,
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
type CreateProject,
|
||||
type UpdateProject,
|
||||
type AddProjectAccessMember,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
|
||||
@@ -680,6 +680,7 @@ export type {
|
||||
IssueSubtreeDiagnosticEdge,
|
||||
IssueSubtreeDiagnosticsResponse,
|
||||
IssueVisibility,
|
||||
IssuePrivacyConstraints,
|
||||
IssueAccessGrant,
|
||||
IssueAccessGrantAgentVisibility,
|
||||
IssueAccessGrantSubjectType,
|
||||
|
||||
@@ -47,6 +47,11 @@ import type {
|
||||
export type { IssueWorkMode };
|
||||
|
||||
export type IssueVisibility = "open" | "private";
|
||||
/** Management hints for this task; never includes protected project/parent identity. */
|
||||
export interface IssuePrivacyConstraints {
|
||||
publicBlockedBy: "parent" | "project" | null;
|
||||
leavesPersonalProject: boolean;
|
||||
}
|
||||
export type IssueAccessGrantSubjectType = "user" | "agent";
|
||||
export type IssueAccessGrantSource = "explicit" | "assignment" | "project" | "owner";
|
||||
export type IssueAccessGrantAgentVisibility = "discoverable" | "private";
|
||||
|
||||
@@ -390,12 +390,14 @@ export {
|
||||
projectDiscoverySchema,
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
projectWorkspaceRuntimeConfigSchema,
|
||||
type CreateProject,
|
||||
type UpdateProject,
|
||||
type AddProjectAccessMember,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
import { createAgentSchema } from "./agent.js";
|
||||
|
||||
describe("issue validators", () => {
|
||||
it("accepts private visibility without adding defaults to updates", () => {
|
||||
expect(createIssueSchema.parse({ title: "Private", visibility: "private" }).visibility).toBe("private");
|
||||
expect(createIssueSchema.parse({ title: "Open" }).visibility).toBe("open");
|
||||
expect(updateIssueSchema.parse({}).visibility).toBeUndefined();
|
||||
expect(updateIssueSchema.parse({ visibility: "private" }).visibility).toBe("private");
|
||||
expect(updateIssueSchema.safeParse({ visibility: "secret" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates the typed recovery display snapshot while retaining older metadata", () => {
|
||||
const metadata = { version: 1, sections: [{ rows: [{ type: "text", text: "Details" }] }] };
|
||||
const recovery = { kind: "disposition_repair_escalated", actionId: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted", assigneeAgentId: null };
|
||||
|
||||
@@ -689,6 +689,7 @@ const createIssueBaseSchema = z.object({
|
||||
projectId: z.string().guid().optional().nullable(),
|
||||
projectWorkspaceId: z.string().guid().optional().nullable(),
|
||||
goalId: z.string().guid().optional().nullable(),
|
||||
visibility: z.enum(["open", "private"]).optional().default("open"),
|
||||
parentId: z.string().guid().optional().nullable(),
|
||||
blockedByIssueIds: z.array(z.string().guid()).optional(),
|
||||
unblockDescriptor: z
|
||||
|
||||
@@ -111,6 +111,7 @@ const projectFields = {
|
||||
goalIds: z.array(z.string().guid()).optional(),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
visibility: z.enum(["open", "private"]).optional().default("open"),
|
||||
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
|
||||
leadAgentId: z.string().guid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
@@ -137,4 +138,11 @@ export const updateProjectSchema = objectWithoutDefaults(
|
||||
|
||||
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
export const addProjectAccessMemberSchema = z.object({
|
||||
subjectType: z.enum(["user", "agent"]),
|
||||
subjectId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AddProjectAccessMember = z.infer<typeof addProjectAccessMemberSchema>;
|
||||
|
||||
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
|
||||
@@ -31,6 +31,7 @@ const nonServerProjects = [
|
||||
"@paperclipai/adapter-cursor-local",
|
||||
"@paperclipai/adapter-gemini-local",
|
||||
"@paperclipai/adapter-grok-local",
|
||||
"@paperclipai/hermes-paperclip-adapter",
|
||||
"@paperclipai/adapter-kimi-local",
|
||||
"@paperclipai/adapter-openclaw-gateway",
|
||||
"@paperclipai/adapter-opencode-local",
|
||||
|
||||
@@ -42,6 +42,14 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/authorization.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/authorization.js")>();
|
||||
return {
|
||||
...actual,
|
||||
issueReadSqlCondition: vi.fn(async () => ({}) as any),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-action-audit.js", () => ({
|
||||
agentActionAuditService: () => mockAgentActionAuditService,
|
||||
}));
|
||||
@@ -219,6 +227,7 @@ describe("activity routes", () => {
|
||||
entityType: undefined,
|
||||
entityId: undefined,
|
||||
limit: 100,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,6 +246,7 @@ describe("activity routes", () => {
|
||||
entityType: "issue",
|
||||
entityId: undefined,
|
||||
limit: 500,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { activityService } from "../services/activity.ts";
|
||||
import { issueReadSqlCondition } from "../services/authorization.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { documentService } from "../services/documents.ts";
|
||||
|
||||
@@ -150,6 +151,54 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]);
|
||||
});
|
||||
|
||||
it("filters company activity rows whose entity is a private issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const privateIssueId = randomUUID();
|
||||
const openIssueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private activity",
|
||||
issuePrefix: `A${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
title: "Confidential activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
},
|
||||
{
|
||||
id: openIssueId,
|
||||
companyId,
|
||||
title: "Open activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId, actorType: "system", actorId: "system", action: "private.changed", entityType: "issue", entityId: privateIssueId },
|
||||
{ companyId, actorType: "system", actorId: "system", action: "open.changed", entityType: "issue", entityId: openIssueId },
|
||||
]);
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
source: "session" as const,
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
|
||||
const rows = await activityService(db).list({
|
||||
companyId,
|
||||
readCondition: await issueReadSqlCondition(db, actor),
|
||||
});
|
||||
expect(rows.map((row) => row.action)).toContain("open.changed");
|
||||
expect(rows.map((row) => row.action)).not.toContain("private.changed");
|
||||
});
|
||||
|
||||
it.each([null, "native_provider_model_rejected", "adapter_failed"])("returns compact issue runs with bounded model rejection details: %s", async (errorCode) => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
@@ -175,10 +224,21 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Summarize a completed run",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: errorCode ? "failed" : "succeeded",
|
||||
errorCode,
|
||||
@@ -295,6 +355,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:00:00.000Z"),
|
||||
@@ -389,6 +451,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:00:00.000Z"),
|
||||
@@ -404,6 +468,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: otherRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:05:00.000Z"),
|
||||
@@ -511,6 +577,8 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-04-18T20:10:00.000Z"),
|
||||
|
||||
@@ -354,6 +354,8 @@ describe("agent live run routes", () => {
|
||||
mockHeartbeatService.getRunLogAccess.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
logStore: "local_file",
|
||||
logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson",
|
||||
});
|
||||
@@ -598,6 +600,8 @@ describe("agent live run routes", () => {
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
logStore: "local_file",
|
||||
logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson",
|
||||
},
|
||||
@@ -687,7 +691,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
@@ -731,7 +736,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
@@ -774,7 +780,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
|
||||
const selectCalls: Array<ReturnType<typeof vi.fn>> = [];
|
||||
@@ -834,7 +841,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
const recentRows = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `run-recent-${index}`,
|
||||
@@ -861,7 +869,8 @@ describe("agent live run routes", () => {
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: 0,
|
||||
processStartedAt: null,
|
||||
issueId: "issue-1",
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
}));
|
||||
|
||||
let selectCallCount = 0;
|
||||
|
||||
@@ -34,6 +34,10 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/authorization.js", async (importActual) => ({
|
||||
...await importActual<typeof import("../services/authorization.js")>(),
|
||||
canActorReadApproval: async () => true,
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
|
||||
@@ -103,7 +103,7 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
app.use("/api", assetRoutes({ select: () => ({ from: () => ({ leftJoin: () => ({ where: async () => [] }), where: async () => [] }) }) } as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
createdAt?: Date;
|
||||
unblockDescriptor?: { owner: { userId: string } | "board"; action: string } | null;
|
||||
blockedTransitionAt?: Date | null;
|
||||
visibility?: "open" | "private";
|
||||
privacyRootIssueId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
harnessKind?: string | null;
|
||||
reviewPolicy?: "anyone" | "not_creator" | "human_only" | null;
|
||||
}) {
|
||||
@@ -195,6 +198,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
executionState: input.executionState ?? null,
|
||||
unblockDescriptor: input.unblockDescriptor ?? null,
|
||||
blockedTransitionAt: input.blockedTransitionAt ?? null,
|
||||
visibility: input.visibility ?? "open",
|
||||
privacyRootIssueId: input.privacyRootIssueId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
harnessKind: input.harnessKind ?? null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
@@ -2040,6 +2046,35 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
|
||||
it("omits private issue review items for a non-member board user", async () => {
|
||||
const { companyId } = await seedCompany("ATP");
|
||||
const privateIssueId = randomUUID();
|
||||
await insertIssue({
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
identifier: "ATP-1",
|
||||
title: "Confidential review",
|
||||
status: "in_review",
|
||||
assigneeUserId: "private-owner",
|
||||
executionState: pendingUserExecutionState("private-owner"),
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
});
|
||||
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
source: "session" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
const feed = await attentionService(db).list(companyId, { userId: actor.userId, actor });
|
||||
|
||||
expect(feed.items.map((item) => item.subject.title)).not.toContain("Confidential review");
|
||||
expect(JSON.stringify(feed)).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => {
|
||||
const { companyId, workerId } = await seedCompany("AGE");
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
companyMemberships,
|
||||
createDb,
|
||||
instanceUserRoles,
|
||||
issueAccessGrants,
|
||||
issueComments,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -19,7 +21,13 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
import {
|
||||
authorizationService,
|
||||
canActorReadIssuePrivacy,
|
||||
issuePrivacyMode,
|
||||
type AuthorizationActor,
|
||||
} from "../services/authorization.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -56,12 +64,18 @@ async function createAgent(
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createProject(db: ReturnType<typeof createDb>, companyId: string, label: string) {
|
||||
async function createProject(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
label: string,
|
||||
visibility: "open" | "private" = "open",
|
||||
) {
|
||||
return db
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Project ${label} ${randomUUID()}`,
|
||||
visibility,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
@@ -76,6 +90,12 @@ async function createIssue(
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
visibility?: "open" | "private";
|
||||
privacyRootIssueId?: string | null;
|
||||
privacyParentIssueId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
} = {},
|
||||
@@ -91,6 +111,12 @@ async function createIssue(
|
||||
projectId: input.projectId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
visibility: input.visibility ?? "open",
|
||||
privacyRootIssueId: input.privacyRootIssueId ?? null,
|
||||
privacyParentIssueId: input.privacyParentIssueId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
})
|
||||
@@ -167,6 +193,21 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
it("defaults issue privacy to enforce while retaining explicit rollout overrides", () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
expect(issuePrivacyMode()).toBe("enforce");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
expect(issuePrivacyMode()).toBe("shadow");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off";
|
||||
expect(issuePrivacyMode()).toBe("off");
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
@@ -178,6 +219,7 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
@@ -2744,6 +2786,199 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
});
|
||||
|
||||
it("enforces private issue implicit principals, grants, revocation, and subtree inheritance", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
const previousTtl = process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = "5000";
|
||||
try {
|
||||
const company = await createCompany(db, "IssuePrivacy");
|
||||
const ownerId = await createUser(db);
|
||||
const memberId = await createUser(db);
|
||||
const granteeId = await createUser(db);
|
||||
await db.insert(companyMemberships).values([ownerId, memberId, granteeId].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const assignedAgent = await createAgent(db, company.id);
|
||||
const otherAgent = await createAgent(db, company.id);
|
||||
const authz = authorizationService(db);
|
||||
const userActor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session" });
|
||||
const agentActor = (agentId: string): AuthorizationActor => ({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId: company.id,
|
||||
source: "agent_key",
|
||||
});
|
||||
const decide = (actor: AuthorizationActor, issue: Awaited<ReturnType<typeof createIssue>>) => authz.decide({
|
||||
actor,
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
|
||||
const openIssue = await createIssue(db, company.id);
|
||||
await expect(decide(userActor(memberId), openIssue)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const privateRootId = randomUUID();
|
||||
const privateRoot = await createIssue(db, company.id, {
|
||||
id: privateRootId,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateRootId,
|
||||
responsibleUserId: ownerId,
|
||||
assigneeAgentId: assignedAgent.id,
|
||||
});
|
||||
await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decide(agentActor(otherAgent.id), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decide(userActor(ownerId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decide(agentActor(assignedAgent.id), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const grant = await db.insert(issueAccessGrants).values({
|
||||
issueId: privateRoot.id,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const child = await createIssue(db, company.id, {
|
||||
parentId: privateRoot.id,
|
||||
privacyParentIssueId: privateRoot.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateRoot.id,
|
||||
});
|
||||
await expect(decide(userActor(granteeId), child)).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||
await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ allowed: true });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authzMode: "shadow",
|
||||
issueId: privateRoot.id,
|
||||
reason: "deny_issue_private",
|
||||
}),
|
||||
"issue privacy would deny read",
|
||||
);
|
||||
warn.mockRestore();
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
if (previousTtl === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = previousTtl;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses private-project membership as the same issue-read predicate while preserving issue grants", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const company = await createCompany(db, "ProjectPrivacy");
|
||||
const memberId = await createUser(db);
|
||||
const granteeId = await createUser(db);
|
||||
const outsiderId = await createUser(db);
|
||||
await db.insert(companyMemberships).values([memberId, granteeId, outsiderId].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const project = await createProject(db, company.id, "Private", "private");
|
||||
const memberAgent = await createAgent(db, company.id);
|
||||
const outsiderAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values([memberAgent.id, outsiderAgent.id].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "agent" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
})));
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: memberAgent.id,
|
||||
});
|
||||
const issue = await createIssue(db, company.id, { projectId: project.id });
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId: issue.id,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: memberId,
|
||||
});
|
||||
const authz = authorizationService(db);
|
||||
const decideProject = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssue = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
const decideProjectAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssueAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
|
||||
await expect(decideProject(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProject(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssue(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(granteeId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decideProjectAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssueAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProjectAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssueAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("denies secrets proposals for scoped tokens regardless of request source", async () => {
|
||||
const company = await createCompany(db, "SecretProposalScopes");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
|
||||
@@ -190,8 +190,9 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
{ projectId: f.projectId, assigneeAgentId: null, assigneeUserId: "board-user" },
|
||||
]) {
|
||||
const result = await create(path, fields);
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(403);
|
||||
expect(result.body.error).toMatch(/outside.*boundary|cannot assign work to board users|different company/i);
|
||||
const hiddenProject = fields.projectId !== f.projectId && path.startsWith("/api/companies/");
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(hiddenProject ? 404 : 403);
|
||||
expect(result.body.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary|cannot assign work to board users|different company/i);
|
||||
}
|
||||
}
|
||||
const after = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
@@ -232,7 +233,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
body: JSON.stringify({ title: "Root cannot escape into another project", parentId: f.issueId,
|
||||
projectId: outside.id, assigneeAgentId: f.agentId, status: "backlog" }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(403);
|
||||
const denied = await response.json();
|
||||
const hiddenProject = path.startsWith("/api/companies/");
|
||||
expect(response.status, JSON.stringify(denied)).toBe(hiddenProject ? 404 : 403);
|
||||
expect(denied.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary/i);
|
||||
}
|
||||
await expect(call(f, "create_task", { title: "Native root cannot escape", projectId: outside.id,
|
||||
idempotencyKey: "root-outside", status: "backlog" })).rejects.toThrow(/outside.*boundary/);
|
||||
|
||||
@@ -67,13 +67,17 @@ describe("company extract-search route", () => {
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
expect(extract).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
contains: "github.com/example/repo/pull",
|
||||
kind: "url",
|
||||
scope: "comments",
|
||||
limit: 200,
|
||||
matchesPerIssue: 200,
|
||||
}));
|
||||
}),
|
||||
expect.objectContaining({ issueReadCondition: expect.anything() }),
|
||||
);
|
||||
expect(response.body).toMatchObject({ kind: "url", scope: "comments", truncated: false });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documents,
|
||||
issueComments,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companySearchExtractService } from "../services/company-search-extract.js";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -73,6 +75,7 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -266,4 +269,44 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not extract content from a private issue for a non-member", async () => {
|
||||
const companyId = await createCompany();
|
||||
const viewerId = randomUUID();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: viewerId,
|
||||
membershipRole: "operator",
|
||||
status: "active",
|
||||
});
|
||||
const visibleIssueId = await createIssue(companyId, {
|
||||
identifier: "EXT-PUBLIC",
|
||||
description: "privacy-leak-needle",
|
||||
});
|
||||
const privateIssueId = randomUUID();
|
||||
await createIssue(companyId, {
|
||||
id: privateIssueId,
|
||||
identifier: "EXT-PRIVATE",
|
||||
description: "privacy-leak-needle private payload",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: randomUUID(),
|
||||
});
|
||||
const actor: AuthorizationActor = {
|
||||
type: "board",
|
||||
userId: viewerId,
|
||||
source: "session",
|
||||
};
|
||||
|
||||
const result = await svc.extract(
|
||||
companyId,
|
||||
companySearchExtractQuerySchema.parse({ contains: "privacy-leak-needle" }),
|
||||
{ issueReadCondition: await issueReadSqlCondition(db, actor) },
|
||||
);
|
||||
|
||||
expect(result.results.map((row) => row.issueId)).toEqual([visibleIssueId]);
|
||||
expect(JSON.stringify(result)).not.toContain(privateIssueId);
|
||||
expect(JSON.stringify(result)).not.toContain("private payload");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issueLabels,
|
||||
issues,
|
||||
labels,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
companySearchBranchFetchLimit,
|
||||
companySearchService,
|
||||
} from "../services/company-search.js";
|
||||
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -87,6 +91,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueLabels);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(issues);
|
||||
await db.delete(labels);
|
||||
await db.delete(projects);
|
||||
@@ -814,4 +819,99 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected);
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes private issue filtering into list, count, and company search queries", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const ownerId = `owner-${randomUUID()}`;
|
||||
const otherId = `other-${randomUUID()}`;
|
||||
const granteeId = `grantee-${randomUUID()}`;
|
||||
const openId = await createIssue(companyId, { title: "privacy needle open" });
|
||||
const privateId = randomUUID();
|
||||
await createIssue(companyId, {
|
||||
id: privateId,
|
||||
title: "privacy needle private",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateId,
|
||||
responsibleUserId: ownerId,
|
||||
});
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId: privateId,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: ownerId,
|
||||
});
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const conditionFor = (userId: string) => issueReadSqlCondition(db, {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
});
|
||||
const otherCondition = await conditionFor(otherId);
|
||||
const ownerCondition = await conditionFor(ownerId);
|
||||
const granteeCondition = await conditionFor(granteeId);
|
||||
|
||||
await expect(issuesSvc.list(companyId, { readCondition: otherCondition })).resolves.toMatchObject([
|
||||
{ id: openId },
|
||||
]);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: otherCondition })).resolves.toBe(1);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: ownerCondition })).resolves.toBe(2);
|
||||
await expect(issuesSvc.count(companyId, { readCondition: granteeCondition })).resolves.toBe(2);
|
||||
|
||||
const result = await svc.search(
|
||||
companyId,
|
||||
companySearchQuerySchema.parse({ q: "privacy needle", scope: "issues" }),
|
||||
{ issueReadCondition: otherCondition },
|
||||
);
|
||||
expect(result.results.map((row) => row.id)).toEqual([openId]);
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes private project filtering into project search and counts", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const outsiderId = `outsider-${randomUUID()}`;
|
||||
const openId = await createProject(companyId, { name: "privacy project open" });
|
||||
const privateId = await createProject(companyId, {
|
||||
name: "privacy project private",
|
||||
visibility: "private",
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId,
|
||||
projectId: privateId,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
const conditionFor = (userId: string) => projectReadSqlCondition(db, {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
});
|
||||
const query = companySearchQuerySchema.parse({ q: "privacy project", scope: "projects" });
|
||||
const outsiderResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(outsiderId),
|
||||
});
|
||||
expect(outsiderResult.results.map((row) => row.id)).toEqual([openId]);
|
||||
expect(outsiderResult.countsByType.project).toBe(1);
|
||||
|
||||
const memberResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(memberId),
|
||||
});
|
||||
expect(memberResult.results.map((row) => row.id)).toEqual(expect.arrayContaining([openId, privateId]));
|
||||
expect(memberResult.countsByType.project).toBe(2);
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
aiProviderDefaults,
|
||||
agentWakeupRequests,
|
||||
issueComments,
|
||||
@@ -110,6 +111,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
issuePrefix: "CONN",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({ id: "responsible-user", name: "Responsible user", email: "responsible@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -87,7 +87,6 @@ describePg("decisionService", () => {
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "comment_on_issue", targetIssueId, staleness, bodyMarkdown: "hello" }] }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Make an existing decision TTL-expired for the next sweep. Creating a
|
||||
// decision that is already expired is impossible (create rejects a past
|
||||
// expiresAt), and creating one that expires a few milliseconds later races
|
||||
|
||||
@@ -62,6 +62,12 @@ vi.mock("../services/index.js", () => ({
|
||||
workspaceOperationService: () => ({}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(async (input: { action: string }) => ({
|
||||
allowed: ["project:read", "issue:read"].includes(input.action),
|
||||
action: input.action,
|
||||
reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.",
|
||||
})),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -7,6 +8,14 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// This unit suite isolates runtime lifecycle behavior; privacy is covered with a
|
||||
// real database by privacy-production-review.test.ts.
|
||||
vi.mock("../services/authorization.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../services/authorization.js")>("../services/authorization.js")),
|
||||
executionWorkspaceReadSqlCondition: async () => sql<boolean>`true`,
|
||||
canActorReadExecutionWorkspace: async () => true,
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -157,8 +166,8 @@ function createRegisteredRepairFixture(
|
||||
prefix: string,
|
||||
options: { targetInstanceId?: string; withCli?: boolean } = {},
|
||||
) {
|
||||
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const baseCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`));
|
||||
const workspaceCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
||||
const baseCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`)));
|
||||
const configDir = path.join(workspaceCwd, ".paperclip");
|
||||
const sourceConfigPath = path.join(baseCwd, ".paperclip", "config.json");
|
||||
const targetInstanceId = options.targetInstanceId ?? "repair-target";
|
||||
@@ -300,7 +309,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/stop`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
||||
expect(res.body.details ?? res.body).toMatchObject({
|
||||
code: "workspace_runtime_control_in_progress",
|
||||
activeAction: "start",
|
||||
@@ -322,7 +331,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -367,7 +376,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "seed_manifest_malformed",
|
||||
@@ -415,7 +424,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
@@ -450,7 +459,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
@@ -471,7 +480,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
@@ -493,7 +502,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "seed_manifest_instance_mismatch",
|
||||
@@ -573,7 +582,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executionWorkspaceId, workspaceCwd: fixture.workspaceCwd }),
|
||||
);
|
||||
@@ -604,7 +613,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
||||
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
|
||||
@@ -656,7 +665,7 @@ describe("execution workspace runtime control conflict and failure reconciliatio
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Internal server error" });
|
||||
expect(progress).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ metadata: { seedFailurePhase: "restore" } }),
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js";
|
||||
|
||||
vi.mock("../services/authorization.js", async (importActual) => ({
|
||||
...await importActual<typeof import("../services/authorization.js")>(),
|
||||
executionWorkspaceReadSqlCondition: async () => sql<boolean>`true`,
|
||||
canActorReadExecutionWorkspace: async () => true,
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listOverview: vi.fn(),
|
||||
@@ -133,6 +140,7 @@ describe("execution workspace routes", () => {
|
||||
},
|
||||
]);
|
||||
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", {
|
||||
readCondition: expect.anything(),
|
||||
projectId: undefined,
|
||||
projectWorkspaceId: undefined,
|
||||
issueId: undefined,
|
||||
@@ -159,7 +167,7 @@ describe("execution workspace routes", () => {
|
||||
status: ["active", "idle"],
|
||||
limit: 25,
|
||||
offset: 10,
|
||||
});
|
||||
}, expect.anything());
|
||||
});
|
||||
|
||||
it("rejects invalid workspace overview pagination", async () => {
|
||||
|
||||
@@ -224,11 +224,13 @@ describe("external object routes", () => {
|
||||
const app = await createApp(ownerActor());
|
||||
|
||||
const summary = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
|
||||
expect(summary.status).toBe(403);
|
||||
expect(summary.status).toBe(404);
|
||||
expect(summary.body.error).toBe("Issue not found");
|
||||
expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled();
|
||||
|
||||
const list = await request(app).get(`/api/issues/${issueId}/external-objects`);
|
||||
expect(list.status).toBe(403);
|
||||
expect(list.status).toBe(404);
|
||||
expect(list.body.error).toBe("Issue not found");
|
||||
expect(mockExternalObjectsService.listForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -208,6 +208,20 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("withholds private task files and private cross-project targets", async () => {
|
||||
const { projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
||||
await fs.writeFile(path.join(projectRoot, "secret.txt"), "PRIVATE_FILE", "utf8");
|
||||
await fs.writeFile(path.join(targetProjectRoot, "secret.txt"), "PRIVATE_TARGET", "utf8");
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
await db.update(issues).set({ visibility: "private", responsibleUserId: "owner" }).where(eq(issues.id, graph.issueId));
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(404);
|
||||
await db.update(issues).set({ visibility: "open" }).where(eq(issues.id, graph.issueId));
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(200);
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId));
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "secret.txt" }).expect(404);
|
||||
});
|
||||
|
||||
it("resolves and reads a project file without exposing absolute paths", async () => {
|
||||
const { root, projectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot });
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
activityLog,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueAccessGrants,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-privacy-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const ownerAgentId = randomUUID();
|
||||
const grantedAgentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
const ownerUserId = `owner-${randomUUID()}`;
|
||||
const otherUserId = `other-${randomUUID()}`;
|
||||
const issueId = randomUUID();
|
||||
const privateRunId = randomUUID();
|
||||
const maintenanceRunId = randomUUID();
|
||||
const operationId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Run Privacy",
|
||||
issuePrefix: "RPR",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values([ownerUserId, otherUserId].map((id) => ({
|
||||
id,
|
||||
name: id === ownerUserId ? "Owner user" : "Other user",
|
||||
email: `${id}@example.test`,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})));
|
||||
await db.insert(companyMemberships).values([ownerUserId, otherUserId].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: ownerAgentId,
|
||||
companyId,
|
||||
name: "Owner",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: grantedAgentId,
|
||||
companyId,
|
||||
name: "Granted reviewer",
|
||||
role: "security",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "Other agent",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "RPR-1",
|
||||
title: "Confidential email triage",
|
||||
description: "private@example.test",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: issueId,
|
||||
status: "in_progress",
|
||||
responsibleUserId: ownerUserId,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
});
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId,
|
||||
subjectType: "agent",
|
||||
subjectId: grantedAgentId,
|
||||
source: "explicit",
|
||||
grantedByAgentId: ownerAgentId,
|
||||
});
|
||||
|
||||
const startedAt = new Date("2026-07-31T12:00:00.000Z");
|
||||
const finishedAt = new Date("2026-07-31T12:01:00.000Z");
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: privateRunId,
|
||||
companyId,
|
||||
agentId: ownerAgentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "assignment",
|
||||
status: "running",
|
||||
startedAt,
|
||||
finishedAt,
|
||||
usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 },
|
||||
resultJson: { summary: "Read the confidential email", costUsd: 0.42 },
|
||||
contextSnapshot: { issueId, title: "Confidential email triage" },
|
||||
logStore: "local_file",
|
||||
logRef: "missing-private-run-log.ndjson",
|
||||
},
|
||||
{
|
||||
id: maintenanceRunId,
|
||||
companyId,
|
||||
agentId: ownerAgentId,
|
||||
scopeKind: "company",
|
||||
issueId: null,
|
||||
invocationSource: "timer",
|
||||
status: "succeeded",
|
||||
startedAt,
|
||||
finishedAt,
|
||||
resultJson: { summary: "Maintenance complete", costUsd: 0.01 },
|
||||
contextSnapshot: { wakeReason: "heartbeat_timer" },
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId,
|
||||
runId: privateRunId,
|
||||
agentId: ownerAgentId,
|
||||
seq: 1,
|
||||
eventType: "output",
|
||||
stream: "stdout",
|
||||
message: "private@example.test",
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
id: operationId,
|
||||
companyId,
|
||||
heartbeatRunId: privateRunId,
|
||||
issueId: null,
|
||||
phase: "provision",
|
||||
status: "succeeded",
|
||||
command: "read private email",
|
||||
stdoutExcerpt: "private@example.test",
|
||||
logStore: "local_file",
|
||||
logRef: "missing-private-operation-log.ndjson",
|
||||
});
|
||||
|
||||
return {
|
||||
companyId,
|
||||
ownerAgentId,
|
||||
grantedAgentId,
|
||||
otherAgentId,
|
||||
ownerUserId,
|
||||
otherUserId,
|
||||
issueId,
|
||||
privateRunId,
|
||||
maintenanceRunId,
|
||||
operationId,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(companyId: string, agentId: string) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use("/api", activityRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createBoardApp(companyId: string, userId: string) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns 404 for direct private-run content and metadata-only list stubs to a non-member", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const app = createApp(fixture.companyId, fixture.otherAgentId);
|
||||
|
||||
for (const path of [
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/events`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/log`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/workspace-operations`,
|
||||
`/api/workspace-operations/${fixture.operationId}/log`,
|
||||
]) {
|
||||
const response = await request(app).get(path);
|
||||
expect(response.status, path).toBe(404);
|
||||
expect(JSON.stringify(response.body)).not.toContain("private@example.test");
|
||||
}
|
||||
|
||||
const history = await request(app).get(`/api/companies/${fixture.companyId}/heartbeat-runs`);
|
||||
expect(history.status).toBe(200);
|
||||
const privateStub = history.body.find((run: { id: string }) => run.id === fixture.privateRunId);
|
||||
expect(privateStub).toMatchObject({
|
||||
redacted: true,
|
||||
durationMs: 60_000,
|
||||
costUsd: 0.42,
|
||||
usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 },
|
||||
});
|
||||
expect(privateStub).not.toHaveProperty("issueId");
|
||||
expect(privateStub).not.toHaveProperty("contextSnapshot");
|
||||
expect(privateStub).not.toHaveProperty("resultJson");
|
||||
expect(JSON.stringify(privateStub)).not.toContain("Confidential email triage");
|
||||
expect(JSON.stringify(privateStub)).not.toContain("summary");
|
||||
|
||||
const summarizedHistory = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/heartbeat-runs?summary=true`);
|
||||
const summarizedStub = summarizedHistory.body.find((run: { id: string }) => run.id === fixture.privateRunId);
|
||||
expect(summarizedStub).toMatchObject({ redacted: true, durationMs: 60_000, costUsd: 0.42 });
|
||||
expect(summarizedStub.usageJson).toMatchObject({ inputTokens: 11, outputTokens: 7 });
|
||||
|
||||
const live = await request(app).get(`/api/companies/${fixture.companyId}/live-runs`);
|
||||
expect(live.status).toBe(200);
|
||||
expect(live.body[0]).toMatchObject({ id: fixture.privateRunId, redacted: true, costUsd: 0.42 });
|
||||
expect(live.body[0]).not.toHaveProperty("issueId");
|
||||
|
||||
const maintenance = await request(app).get(`/api/heartbeat-runs/${fixture.maintenanceRunId}`);
|
||||
expect(maintenance.status).toBe(200);
|
||||
expect(maintenance.body.resultJson.summary).toBe("Maintenance complete");
|
||||
});
|
||||
|
||||
it("returns full private-run content to the owner and an explicitly granted agent", async () => {
|
||||
const fixture = await seedFixture();
|
||||
|
||||
for (const agentId of [fixture.ownerAgentId, fixture.grantedAgentId]) {
|
||||
const app = createApp(fixture.companyId, agentId);
|
||||
const detail = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toMatchObject({ issueId: fixture.issueId });
|
||||
expect(detail.body.resultJson.summary).toBe("Read the confidential email");
|
||||
|
||||
const events = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/events`);
|
||||
expect(events.status).toBe(200);
|
||||
expect(events.body[0].message).toBe("private@example.test");
|
||||
|
||||
const linkedIssues = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`);
|
||||
expect(linkedIssues.status).toBe(200);
|
||||
expect(linkedIssues.body[0]).toMatchObject({ issueId: fixture.issueId, title: "Confidential email triage" });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns no linked issue metadata to a non-member", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const response = await request(createApp(fixture.companyId, fixture.otherAgentId))
|
||||
.get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("conceals private runs from unauthorized cancellation and watchdog mutations", async () => {
|
||||
const fixture = await seedFixture();
|
||||
|
||||
const deniedCancel = await request(createBoardApp(fixture.companyId, fixture.otherUserId))
|
||||
.post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`);
|
||||
expect(deniedCancel.status).toBe(404);
|
||||
|
||||
const deniedWatchdog = await request(createApp(fixture.companyId, fixture.otherAgentId))
|
||||
.post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`)
|
||||
.send({ decision: "continue" });
|
||||
expect(deniedWatchdog.status).toBe(404);
|
||||
|
||||
const allowedWatchdog = await request(createBoardApp(fixture.companyId, fixture.ownerUserId))
|
||||
.post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`)
|
||||
.send({ decision: "continue" });
|
||||
expect(allowedWatchdog.status).toBe(200);
|
||||
|
||||
await db.update(heartbeatRuns)
|
||||
.set({ status: "succeeded" })
|
||||
.where(eq(heartbeatRuns.id, fixture.privateRunId));
|
||||
const allowedCancel = await request(createBoardApp(fixture.companyId, fixture.ownerUserId))
|
||||
.post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`);
|
||||
expect(allowedCancel.status).toBe(200);
|
||||
expect(allowedCancel.body.id).toBe(fixture.privateRunId);
|
||||
});
|
||||
|
||||
it("keeps deleted-issue runs and operation history bound to a fail-closed tombstone", async () => {
|
||||
const fixture = await seedFixture();
|
||||
|
||||
await db.delete(issues).where(eq(issues.id, fixture.issueId));
|
||||
|
||||
const missingDetail = await request(createApp(fixture.companyId, fixture.ownerAgentId))
|
||||
.get(`/api/heartbeat-runs/${fixture.privateRunId}`);
|
||||
expect(missingDetail.status).toBe(404);
|
||||
|
||||
const [tombstonedRun] = await db.select({
|
||||
scopeKind: heartbeatRuns.scopeKind,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, fixture.privateRunId));
|
||||
expect(tombstonedRun).toEqual({ scopeKind: "issue", issueId: null });
|
||||
|
||||
const [operation] = await db.select({
|
||||
heartbeatRunId: workspaceOperations.heartbeatRunId,
|
||||
issueId: workspaceOperations.issueId,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(eq(workspaceOperations.id, fixture.operationId));
|
||||
expect(operation).toEqual({ heartbeatRunId: fixture.privateRunId, issueId: null });
|
||||
|
||||
const missingOperationLog = await request(createApp(fixture.companyId, fixture.ownerAgentId))
|
||||
.get(`/api/workspace-operations/${fixture.operationId}/log`);
|
||||
expect(missingOperationLog.status).toBe(404);
|
||||
});
|
||||
|
||||
it("logs would-deny decisions without enforcing them in shadow mode", async () => {
|
||||
const fixture = await seedFixture();
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
try {
|
||||
const response = await request(createApp(fixture.companyId, fixture.otherAgentId))
|
||||
.get(`/api/heartbeat-runs/${fixture.privateRunId}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.resultJson.summary).toBe("Read the confidential email");
|
||||
} finally {
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueAccessGrants,
|
||||
issueComments,
|
||||
issueReferenceMentions,
|
||||
issueRelations,
|
||||
issues,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
vi.mock("../services/issue-assignment-wakeup.js", () => ({
|
||||
queueIssueAssignmentWakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
function boardActor(companyId: string, userId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
|
||||
return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt" };
|
||||
}
|
||||
|
||||
async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use("/api", projectRoutes(db));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue access grant and locked edge routes", { concurrent: false }, () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-access-grants-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
// Load the current route dependency graph during setup, outside assertion timeouts.
|
||||
await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]);
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReferenceMentions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(projectAccessMembers);
|
||||
await db.delete(projects);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(authUsers);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const ownerId = `owner-${randomUUID()}`;
|
||||
const adminId = `admin-${randomUUID()}`;
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const sharedUserId = `shared-${randomUUID()}`;
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private issue grants",
|
||||
issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 5).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values([
|
||||
{ id: ownerId, name: "Olivia Owner", email: `${ownerId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: adminId, name: "Ada Admin", email: `${adminId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: memberId, name: "Nora Nonreader", email: `${memberId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: sharedUserId,
|
||||
name: "Uma User",
|
||||
email: `${sharedUserId}@example.test`,
|
||||
image: "https://example.test/uma.png",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
await db.insert(companyMemberships).values([ownerId, adminId, memberId, sharedUserId].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: principalId === ownerId ? "owner" : principalId === adminId ? "admin" : "operator",
|
||||
})));
|
||||
const [assignedAgent, sharedAgent] = await db.insert(agents).values([
|
||||
{
|
||||
companyId,
|
||||
name: "Assigned Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
name: "Private Helper",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
]).returning();
|
||||
const privateProject = await db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Secret launch project",
|
||||
visibility: "private",
|
||||
status: "planned",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
identifier: "PRV-1",
|
||||
title: "Confidential root",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
projectId: privateProject.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
createdByUserId: ownerId,
|
||||
});
|
||||
const privateChild = await db.insert(issues).values({
|
||||
companyId,
|
||||
parentId: privateIssueId,
|
||||
identifier: "PRV-1A",
|
||||
title: "Confidential child",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
projectId: privateProject.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
createdByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const openIssue = await db.insert(issues).values({
|
||||
companyId,
|
||||
identifier: "PRV-2",
|
||||
title: "Open issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
return {
|
||||
companyId,
|
||||
ownerId,
|
||||
adminId,
|
||||
memberId,
|
||||
sharedUserId,
|
||||
assignedAgent: assignedAgent!,
|
||||
sharedAgent: sharedAgent!,
|
||||
privateProject,
|
||||
privateIssueId,
|
||||
privateChild,
|
||||
openIssue,
|
||||
};
|
||||
}
|
||||
|
||||
it("requires explicit owner/admin break-glass and records both audit evidence and an owner-visible notice", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const adminApp = await createApp(db, boardActor(fixture.companyId, fixture.adminId));
|
||||
const memberApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
|
||||
await request(adminApp).get(`/api/issues/${fixture.privateIssueId}`).expect(404);
|
||||
await request(memberApp).get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`).expect(404);
|
||||
|
||||
const accessed = await request(adminApp)
|
||||
.get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`);
|
||||
expect(accessed.status, JSON.stringify(accessed.body)).toBe(200);
|
||||
expect(accessed.body.id).toBe(fixture.privateIssueId);
|
||||
|
||||
const auditRows = await db.select().from(activityLog);
|
||||
expect(auditRows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
action: "issue.break_glass_read",
|
||||
actorType: "user",
|
||||
actorId: fixture.adminId,
|
||||
entityType: "issue",
|
||||
entityId: fixture.privateIssueId,
|
||||
}),
|
||||
]));
|
||||
const notices = await db.select().from(issueComments);
|
||||
expect(notices).toEqual([
|
||||
expect.objectContaining({
|
||||
issueId: fixture.privateIssueId,
|
||||
authorType: "system",
|
||||
presentation: expect.objectContaining({
|
||||
kind: "system_notice",
|
||||
title: "Break-glass access used",
|
||||
tone: "warning",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}, 20_000);
|
||||
|
||||
it("auto-grants child-subtree access on assignment and keeps it sticky until revoke", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
|
||||
|
||||
const assigned = await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id });
|
||||
expect(assigned.status, JSON.stringify(assigned.body)).toBe(200);
|
||||
|
||||
await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id })
|
||||
.expect(200);
|
||||
await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: null })
|
||||
.expect(200);
|
||||
const unassignedIssue = await db.select().from(issues).then((rows) => rows.find((row) => row.id === fixture.privateChild.id));
|
||||
expect(unassignedIssue?.assigneeAgentId).toBeNull();
|
||||
|
||||
const grants = await db.select().from(issueAccessGrants);
|
||||
expect(grants).toHaveLength(1);
|
||||
expect(grants[0]).toMatchObject({
|
||||
issueId: fixture.privateChild.id,
|
||||
subjectType: "agent",
|
||||
subjectId: fixture.assignedAgent.id,
|
||||
source: "assignment",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const readableBeforeRevoke = await request(await createApp(
|
||||
db,
|
||||
agentActor(fixture.companyId, fixture.assignedAgent.id),
|
||||
)).get(`/api/issues/${fixture.privateChild.id}`);
|
||||
expect(readableBeforeRevoke.status).toBe(200);
|
||||
|
||||
await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateChild.id}/access-grants/${grants[0]!.id}/revoke`)
|
||||
.send({})
|
||||
.expect(200);
|
||||
const deniedAfterRevoke = await request(await createApp(
|
||||
db,
|
||||
agentActor(fixture.companyId, fixture.assignedAgent.id),
|
||||
)).get(`/api/issues/${fixture.privateChild.id}`);
|
||||
expect(deniedAfterRevoke.status).toBe(404);
|
||||
expect(deniedAfterRevoke.body.error).toBe("Issue not found");
|
||||
}, 20_000);
|
||||
|
||||
it("creates and enriches explicit grants idempotently while rejecting unauthorized and open shares", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
|
||||
const nonreaderApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
|
||||
await request(nonreaderApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id })
|
||||
.expect(404);
|
||||
await request(nonreaderApp)
|
||||
.patch(`/api/issues/${fixture.privateIssueId}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id })
|
||||
.expect(404);
|
||||
const openShare = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.openIssue.id}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(openShare.status).toBe(409);
|
||||
expect(openShare.body.error).toContain("Open issues");
|
||||
|
||||
const first = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(first.status, JSON.stringify(first.body)).toBe(201);
|
||||
expect(first.body).toMatchObject({
|
||||
issueId: fixture.privateIssueId,
|
||||
source: "explicit",
|
||||
subjectDisplayName: "Private Helper",
|
||||
subjectInitials: "PH",
|
||||
subjectAvatarUrl: null,
|
||||
agentVisibility: "private",
|
||||
});
|
||||
const duplicate = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(duplicate.status).toBe(200);
|
||||
expect(duplicate.body.id).toBe(first.body.id);
|
||||
|
||||
await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "user", subjectId: fixture.sharedUserId })
|
||||
.expect(201);
|
||||
const listed = await request(ownerApp).get(`/api/issues/${fixture.privateIssueId}/access-grants`);
|
||||
expect(listed.status, JSON.stringify(listed.body)).toBe(200);
|
||||
expect(listed.body).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subjectId: fixture.sharedUserId,
|
||||
subjectDisplayName: "Uma User",
|
||||
subjectAvatarUrl: "https://example.test/uma.png",
|
||||
subjectInitials: "UU",
|
||||
agentVisibility: null,
|
||||
}),
|
||||
]));
|
||||
expect(await db.select().from(issueAccessGrants)).toHaveLength(2);
|
||||
const createdActivities = await db.select().from(activityLog);
|
||||
expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2);
|
||||
}, 20_000);
|
||||
|
||||
it("keeps private project metadata hidden from user and agent issue grantees", async () => {
|
||||
const fixture = await seedFixture();
|
||||
await db.insert(issueAccessGrants).values([
|
||||
{
|
||||
issueId: fixture.privateIssueId,
|
||||
subjectType: "user",
|
||||
subjectId: fixture.sharedUserId,
|
||||
source: "explicit",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
},
|
||||
{
|
||||
issueId: fixture.privateIssueId,
|
||||
subjectType: "agent",
|
||||
subjectId: fixture.sharedAgent.id,
|
||||
source: "explicit",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
},
|
||||
]);
|
||||
|
||||
const granteeApps = [
|
||||
await createApp(db, boardActor(fixture.companyId, fixture.sharedUserId)),
|
||||
await createApp(db, agentActor(fixture.companyId, fixture.sharedAgent.id)),
|
||||
];
|
||||
|
||||
for (const app of granteeApps) {
|
||||
const detail = await request(app).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
|
||||
expect(detail.body.projectId).toBe(fixture.privateProject.id);
|
||||
expect(detail.body.project).toBeNull();
|
||||
|
||||
const heartbeatContext = await request(app)
|
||||
.get(`/api/issues/${fixture.privateIssueId}/heartbeat-context`);
|
||||
expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200);
|
||||
expect(heartbeatContext.body.issue.projectId).toBe(fixture.privateProject.id);
|
||||
expect(heartbeatContext.body.project).toBeNull();
|
||||
|
||||
await request(app).get(`/api/projects/${fixture.privateProject.id}`).expect(404);
|
||||
|
||||
const projectList = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/projects`);
|
||||
expect(projectList.status, JSON.stringify(projectList.body)).toBe(200);
|
||||
expect(projectList.body.map((project: { id: string }) => project.id))
|
||||
.not.toContain(fixture.privateProject.id);
|
||||
|
||||
const projectSearch = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/search?q=Secret%20launch&scope=projects`);
|
||||
expect(projectSearch.status, JSON.stringify(projectSearch.body)).toBe(200);
|
||||
expect(projectSearch.body.results.map((project: { id: string }) => project.id))
|
||||
.not.toContain(fixture.privateProject.id);
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("returns exact locked stubs only through visible blocker and mention edges", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const visibleIssue = await db.insert(issues).values({
|
||||
companyId: fixture.companyId,
|
||||
identifier: "PRV-3",
|
||||
title: "Visible coordination issue",
|
||||
description: "Depends on PRV-1",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
createdByUserId: fixture.memberId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(issueRelations).values({
|
||||
companyId: fixture.companyId,
|
||||
issueId: fixture.privateIssueId,
|
||||
relatedIssueId: visibleIssue.id,
|
||||
type: "blocks",
|
||||
});
|
||||
await db.insert(issueReferenceMentions).values({
|
||||
companyId: fixture.companyId,
|
||||
sourceIssueId: visibleIssue.id,
|
||||
targetIssueId: fixture.privateIssueId,
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
matchedText: "PRV-1",
|
||||
});
|
||||
|
||||
const app = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
const detail = await request(app).get(`/api/issues/${visibleIssue.id}`);
|
||||
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
|
||||
const expectedStub = { id: fixture.privateIssueId, identifier: "PRV-1", locked: true };
|
||||
expect(detail.body.blockedBy).toEqual([expectedStub]);
|
||||
expect(detail.body.relatedWork.outbound[0].issue).toEqual(expectedStub);
|
||||
expect(JSON.stringify(detail.body)).not.toContain("Confidential root");
|
||||
|
||||
const direct = await request(app).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(direct.status).toBe(404);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/issues?includeBlockedBy=true`);
|
||||
expect(list.status, JSON.stringify(list.body)).toBe(200);
|
||||
expect(list.body.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
|
||||
const visibleRow = list.body.find((row: { id: string }) => row.id === visibleIssue.id);
|
||||
expect(visibleRow.blockedBy).toEqual([expectedStub]);
|
||||
|
||||
const search = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/search?q=Confidential%20root&scope=issues`);
|
||||
expect(search.status).toBe(200);
|
||||
expect(search.body.results.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -19,6 +19,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(async ({ action }: { action: string }) => ({
|
||||
action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable",
|
||||
})),
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}));
|
||||
@@ -136,7 +139,17 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(db: unknown = {}) {
|
||||
function createEmptyRelationReadDb() {
|
||||
const query = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(async () => []),
|
||||
};
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(query),
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(db: unknown = createEmptyRelationReadDb()) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
||||
@@ -471,14 +471,16 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
input.action === "issue:comment" ||
|
||||
input.action === "issue:read" ||
|
||||
input.action === "issue:mutate" ||
|
||||
input.action === "company_scope:read",
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "project:read",
|
||||
action: input.action,
|
||||
reason:
|
||||
input.action === "tasks:assign" ||
|
||||
input.action === "issue:comment" ||
|
||||
input.action === "issue:read" ||
|
||||
input.action === "issue:mutate" ||
|
||||
input.action === "company_scope:read"
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "project:read"
|
||||
? "allow_explicit_grant"
|
||||
: "deny_missing_grant",
|
||||
explanation:
|
||||
@@ -486,7 +488,8 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
input.action === "issue:comment" ||
|
||||
input.action === "issue:read" ||
|
||||
input.action === "issue:mutate" ||
|
||||
input.action === "company_scope:read"
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "project:read"
|
||||
? "Allowed by test default."
|
||||
: "Missing permission.",
|
||||
}));
|
||||
@@ -880,7 +883,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
|
||||
it("allows mentioned peer agents to post comments without ownership of an active checkout", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:comment",
|
||||
allowed: input.action === "issue:comment" || input.action === "issue:read",
|
||||
action: input.action,
|
||||
reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : "deny_missing_grant",
|
||||
explanation:
|
||||
@@ -963,8 +966,8 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
const res = await request(await createApp(peerActor()))
|
||||
.get(`/api/issues/${issueId}/comments`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Issue is outside this actor's authorization boundary");
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(404);
|
||||
expect(res.body.error).toBe("Issue not found");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
|
||||
});
|
||||
|
||||
@@ -979,8 +982,8 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
const res = await request(await createApp(peerActor()))
|
||||
.get(`/api/issues/${issueId}/interactions`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Issue is outside this actor's authorization boundary");
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(404);
|
||||
expect(res.body.error).toBe("Issue not found");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
|
||||
expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1025,8 +1028,8 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
const res = await request(await createApp(peerActor()))
|
||||
.get(`/api/issues/${issueId}/comments/comment-1`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Issue is outside this actor's authorization boundary");
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(404);
|
||||
expect(res.body.error).toBe("Issue not found");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
|
||||
expect(mockIssueService.getComment).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1034,7 +1037,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
it("allows visible issue field updates for peer agents", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:comment" || input.action === "issue:mutate",
|
||||
allowed: input.action === "issue:comment" || input.action === "issue:mutate" || input.action === "issue:read",
|
||||
action: input.action,
|
||||
reason:
|
||||
input.action === "issue:comment"
|
||||
@@ -1333,6 +1336,9 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
});
|
||||
|
||||
it("defaults agent-created root follow-up issues to inherit the current run workspace", async () => {
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", companyId, visibility: "open",
|
||||
} as never);
|
||||
const app = await createApp(
|
||||
ownerActor(),
|
||||
createRunContextDb({
|
||||
@@ -1633,7 +1639,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
|
||||
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts",
|
||||
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" || input.action === "issue:read",
|
||||
action: input.action,
|
||||
reason:
|
||||
input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts"
|
||||
@@ -2008,7 +2014,9 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
});
|
||||
|
||||
it.each(["agent", "viewer"])("rejects %s export-only retries before admission", async kind => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, explanation: "No runtime access" });
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:read", explanation: "The viewer can read but has no runtime access",
|
||||
}));
|
||||
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/retry-workspace-export`)
|
||||
.send({ actionId: recoveryActionId, runId: ownerRunId, repairNote: "Restored provider connectivity and preserved all saved files." });
|
||||
@@ -2575,11 +2583,14 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
explanation: "Target agent requires approval before task assignment.",
|
||||
}));
|
||||
decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:mutate",
|
||||
allowed: input.action === "issue:read" || input.action === "issue:mutate",
|
||||
action: input.action,
|
||||
reason: input.action === "issue:mutate" ? "allow_self" : "deny_policy_restricted",
|
||||
reason:
|
||||
input.action === "issue:read" || input.action === "issue:mutate"
|
||||
? "allow_self"
|
||||
: "deny_policy_restricted",
|
||||
explanation:
|
||||
input.action === "issue:mutate"
|
||||
input.action === "issue:read" || input.action === "issue:mutate"
|
||||
? "Allowed because the actor owns the assigned issue."
|
||||
: "Target agent requires approval before task assignment.",
|
||||
}));
|
||||
|
||||
@@ -724,7 +724,8 @@ describe("issue attachment routes", () => {
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app).get("/api/attachments/attachment-1/content");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Issue not found");
|
||||
expect(storage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:read" || input.action === "project:read",
|
||||
action: input.action,
|
||||
reason: "allow_test_read",
|
||||
explanation: "The fixture actor can read the task.",
|
||||
})),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(async ({ action }: { action: string }) => ({
|
||||
action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable",
|
||||
})),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -821,7 +821,7 @@ describe("issue comment reopen routes", () => {
|
||||
});
|
||||
mockAccessService.decide.mockImplementation(
|
||||
async (input: { action?: string }) => {
|
||||
const allowed = input.action === "issue:comment";
|
||||
const allowed = input.action === "issue:read" || input.action === "issue:comment";
|
||||
return {
|
||||
allowed,
|
||||
action: input.action,
|
||||
@@ -869,7 +869,7 @@ describe("issue comment reopen routes", () => {
|
||||
});
|
||||
mockAccessService.decide.mockImplementation(
|
||||
async (input: { action?: string }) => {
|
||||
const allowed = input.action === "issue:comment";
|
||||
const allowed = input.action === "issue:read" || input.action === "issue:comment";
|
||||
return {
|
||||
allowed,
|
||||
action: input.action,
|
||||
|
||||
@@ -39,6 +39,12 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:read" || input.action === "project:read",
|
||||
action: input.action,
|
||||
reason: "allow_test_read",
|
||||
explanation: "The fixture actor can read the task.",
|
||||
})),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
||||
@@ -4,6 +4,8 @@ import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
@@ -47,7 +49,9 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -78,6 +82,8 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({ id: "board-user", name: "Board", email: "board@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "admin" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -28,10 +28,12 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelRun: vi.fn(),
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
const mockAccessService = vi.hoisted(() => ({ decide: vi.fn() }));
|
||||
|
||||
const mockHeartbeatFactory = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
heartbeatService: mockHeartbeatFactory,
|
||||
issueService: () => mockIssueService,
|
||||
issueTreeControlService: () => mockTreeControlService,
|
||||
@@ -87,6 +89,25 @@ describe("issue tree control routes", () => {
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
mockHeartbeatService.wakeup.mockResolvedValue(null);
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: true });
|
||||
});
|
||||
|
||||
it("conceals private roots from non-members before exposing tree state", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-2"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_issue_private" });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview")
|
||||
.send({ mode: "pause" });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockTreeControlService.preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issuePlanDecompositions,
|
||||
issueReadStates,
|
||||
issueRelations,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
toolApplications,
|
||||
@@ -3280,6 +3282,121 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
expect(child.responsibleUserId).toBe(responsibleUserId);
|
||||
});
|
||||
|
||||
it("creates children under a private issue in the same private subtree", async () => {
|
||||
const companyId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private subtree company",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
title: "Private root",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: parentIssueId,
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
title: "Inherited private child",
|
||||
visibility: "open",
|
||||
});
|
||||
|
||||
expect(child.visibility).toBe("private");
|
||||
expect(child.privacyRootIssueId).toBe(parentIssueId);
|
||||
});
|
||||
|
||||
it("creates one personal private project and parks private tasks there", async () => {
|
||||
const companyId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Personal private tasks company",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
svc.create(companyId, {
|
||||
title: "First personal private task",
|
||||
visibility: "private",
|
||||
createdByUserId: responsibleUserId,
|
||||
}),
|
||||
svc.create(companyId, {
|
||||
title: "Second personal private task",
|
||||
visibility: "private",
|
||||
createdByUserId: responsibleUserId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const personalProjects = await db.select().from(projects)
|
||||
.where(eq(projects.personalOwnerUserId, responsibleUserId));
|
||||
expect(personalProjects).toHaveLength(1);
|
||||
expect(personalProjects[0]).toMatchObject({
|
||||
companyId,
|
||||
name: "My private tasks",
|
||||
visibility: "private",
|
||||
});
|
||||
expect(first.projectId).toBe(personalProjects[0]!.id);
|
||||
expect(second.projectId).toBe(personalProjects[0]!.id);
|
||||
expect(first.privacyRootIssueId).toBe(first.id);
|
||||
expect(second.privacyRootIssueId).toBe(second.id);
|
||||
await expect(db.select().from(projectAccessMembers)
|
||||
.where(eq(projectAccessMembers.projectId, personalProjects[0]!.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a responsible human and grants an agent access to its explicitly private task", async () => {
|
||||
const companyId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
const creatorAgentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Agent private task company",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: creatorAgentId,
|
||||
companyId,
|
||||
name: "Private task creator",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Agent email scratch task",
|
||||
visibility: "private",
|
||||
createdByAgentId: creatorAgentId,
|
||||
actorResponsibleUserId: responsibleUserId,
|
||||
});
|
||||
|
||||
expect(issue.responsibleUserId).toBe(responsibleUserId);
|
||||
const personalProject = await db.select().from(projects)
|
||||
.where(eq(projects.id, issue.projectId!))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(personalProject.personalOwnerUserId).toBe(responsibleUserId);
|
||||
await expect(db.select().from(projectAccessMembers)
|
||||
.where(eq(projectAccessMembers.projectId, personalProject.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
||||
]);
|
||||
await expect(db.select().from(issueAccessGrants)
|
||||
.where(eq(issueAccessGrants.issueId, issue.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
subjectType: "agent",
|
||||
subjectId: creatorAgentId,
|
||||
source: "explicit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
|
||||
const companyId = randomUUID();
|
||||
const creatorUserId = randomUUID();
|
||||
|
||||
@@ -1368,10 +1368,8 @@ describeEmbeddedPostgres(
|
||||
expect(
|
||||
unmentionedComment.status,
|
||||
JSON.stringify(unmentionedComment.body),
|
||||
).toBe(403);
|
||||
expect(unmentionedComment.body.details.code).toBe(
|
||||
"issue_write_actor_class_excluded",
|
||||
);
|
||||
).toBe(404);
|
||||
expect(unmentionedComment.body).toEqual({ error: "Issue not found" });
|
||||
});
|
||||
|
||||
it("propagates denied low-trust policy conflicts on control-plane guards", async () => {
|
||||
@@ -1738,7 +1736,7 @@ describeEmbeddedPostgres(
|
||||
const before = await snapshot(db);
|
||||
const res = await attempt.req();
|
||||
expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(
|
||||
403,
|
||||
["LT approvals", "LT-15/16", "LT-19", "LT-26 child", "LT-26 child with unauthorized assignee", "LT-26 company issue", "LT-26 interaction", "LT-06 resume", "LT-06 blocker mutation"].includes(attempt.id) ? 403 : 404,
|
||||
);
|
||||
expectNoCanary(res.body, ...forbiddenMarkers);
|
||||
const after = await snapshot(db);
|
||||
@@ -1881,7 +1879,7 @@ describeEmbeddedPostgres(
|
||||
for (const attempt of attempts) {
|
||||
const res = await attempt.req();
|
||||
expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(
|
||||
403,
|
||||
404,
|
||||
);
|
||||
expectNoCanary(res.body, ...forbiddenMarkers);
|
||||
}
|
||||
|
||||
@@ -244,6 +244,19 @@ function loadSpecRoutes() {
|
||||
}
|
||||
|
||||
describe("openapi routes", () => {
|
||||
it("documents manager-only task privacy hints without protected scope identity", () => {
|
||||
const spec = buildOpenApiSpec() as any;
|
||||
const operation = spec.paths["/api/issues/{id}/privacy-constraints"].get;
|
||||
const schema = operation.responses["200"].content["application/json"].schema;
|
||||
expect(schema.properties).toEqual({
|
||||
publicBlockedBy: { type: "string", enum: ["parent", "project"], nullable: true },
|
||||
leavesPersonalProject: { type: "boolean" },
|
||||
});
|
||||
expect(schema.required).toEqual(["publicBlockedBy", "leavesPersonalProject"]);
|
||||
expect(operation.responses["403"]).toBeDefined();
|
||||
expect(operation.responses["404"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("documents strict run-attributed feedback without a read endpoint", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const path = spec.paths["/api/companies/{companyId}/agent-commentary"];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
@@ -215,14 +217,6 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const blockerIssueId = randomUUID();
|
||||
const originRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: originRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
contextSnapshot: { issueId: blockerIssueId },
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: blockerIssueId,
|
||||
companyId,
|
||||
@@ -231,6 +225,15 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
priority: "medium",
|
||||
identifier: `${issuePrefix(companyId)}-blocker`,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: originRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
contextSnapshot: { issueId: blockerIssueId },
|
||||
});
|
||||
|
||||
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||
const issue = await services.issues.create({
|
||||
@@ -1284,4 +1287,215 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }),
|
||||
).rejects.toThrow("over the");
|
||||
});
|
||||
|
||||
// PAP-16091 / PAP-16050: a plugin is a non-member principal. Every issue-read
|
||||
// path the host exposes (which a Slack notifier/digest plugin would fan out
|
||||
// to shared channels) must apply the canonical visibility predicate so private
|
||||
// issue content and relationship metadata never reach non-members.
|
||||
describe("non-member plugin issue-read privacy (PAP-16091)", () => {
|
||||
let previousMode: string | undefined;
|
||||
beforeEach(() => {
|
||||
previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
});
|
||||
afterEach(() => {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
});
|
||||
|
||||
async function seedOpenAndPrivate() {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const openIssueId = randomUUID();
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: openIssueId, companyId, title: "Public status", status: "todo", priority: "medium", visibility: "open" },
|
||||
{ id: privateIssueId, companyId, title: "Confidential HR matter", status: "todo", priority: "medium", visibility: "private" },
|
||||
]);
|
||||
return { companyId, agentId, openIssueId, privateIssueId };
|
||||
}
|
||||
|
||||
it("omits private issues from issues.list", async () => {
|
||||
const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
const listed = await services.issues.list({ companyId });
|
||||
const ids = listed.map((issue) => issue.id);
|
||||
expect(ids).toContain(openIssueId);
|
||||
expect(ids).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("returns null from issues.get for a private issue", async () => {
|
||||
const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toBeNull();
|
||||
// Sanity: the same path still returns readable (open) issues.
|
||||
await expect(services.issues.get({ issueId: openIssueId, companyId })).resolves.toMatchObject({ id: openIssueId });
|
||||
});
|
||||
|
||||
it("returns no comments from issues.listComments for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
await db.insert(issueComments).values({
|
||||
id: randomUUID(), companyId, issueId: privateIssueId, body: "secret decision rationale",
|
||||
});
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(services.issues.listComments({ issueId: privateIssueId, companyId })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns no attachments from issues.listAttachments for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const assetId = randomUUID();
|
||||
await db.insert(assets).values({
|
||||
id: assetId, companyId, provider: "local_disk", objectKey: "secret", contentType: "image/png", byteSize: 10, sha256: "z",
|
||||
});
|
||||
await db.insert(issueAttachments).values({ id: randomUUID(), companyId, issueId: privateIssueId, assetId });
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(services.issues.listAttachments({ issueId: privateIssueId, companyId })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null from issues.getAttachmentContent for a private issue's attachment", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const assetId = randomUUID();
|
||||
await db.insert(assets).values({
|
||||
id: assetId, companyId, provider: "local_disk", objectKey: "secret2", contentType: "text/plain", byteSize: 4, sha256: "y",
|
||||
});
|
||||
const attachmentId = randomUUID();
|
||||
await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: privateIssueId, assetId });
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("excludes a private descendant and redacts private blocker edges in getOrchestrationSummary", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const rootId = randomUUID();
|
||||
const publicChildId = randomUUID();
|
||||
const privateChildId = randomUUID();
|
||||
const privateBlockerId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" },
|
||||
{ id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" },
|
||||
{ id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" },
|
||||
{ id: privateBlockerId, companyId, title: "Private blocker", status: "todo", priority: "medium", visibility: "private" },
|
||||
]);
|
||||
// privateBlocker blocks the public root -> root.blockedBy includes it pre-redaction.
|
||||
await db.insert(issueRelations).values({
|
||||
companyId, issueId: privateBlockerId, relatedIssueId: rootId, type: "blocks",
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
const summary = await services.issues.getOrchestrationSummary({ companyId, issueId: rootId, includeSubtree: true });
|
||||
|
||||
// Private descendant never enters the subtree.
|
||||
expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootId, publicChildId]));
|
||||
// No relation edge anywhere references the private blocker or private child.
|
||||
const edgeIds = new Set<string>();
|
||||
for (const relation of Object.values(summary.relations)) {
|
||||
for (const edge of [...relation.blockedBy, ...relation.blocks]) edgeIds.add(edge.id);
|
||||
}
|
||||
expect(edgeIds.has(privateBlockerId)).toBe(false);
|
||||
expect(edgeIds.has(privateChildId)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a private root issue as not-found from getOrchestrationSummary", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issues.getOrchestrationSummary({ companyId, issueId: privateIssueId, includeSubtree: true }),
|
||||
).rejects.toThrow("Issue not found");
|
||||
});
|
||||
|
||||
async function seedIssueDocument(companyId: string, issueId: string, key: string, body: string) {
|
||||
const documentId = randomUUID();
|
||||
await db.insert(documents).values({ id: documentId, companyId, latestBody: body, format: "markdown" });
|
||||
await db.insert(issueDocuments).values({ id: randomUUID(), companyId, issueId, documentId, key });
|
||||
return documentId;
|
||||
}
|
||||
|
||||
it("throws not-found from issues.getRelations for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issues.getRelations({ issueId: privateIssueId, companyId }),
|
||||
).rejects.toThrow("Issue not found");
|
||||
});
|
||||
|
||||
it("redacts a private blocker edge from issues.getRelations on a public issue", async () => {
|
||||
const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate();
|
||||
// privateIssue blocks the open issue -> open.blockedBy would name it pre-redaction.
|
||||
await db.insert(issueRelations).values({
|
||||
companyId, issueId: privateIssueId, relatedIssueId: openIssueId, type: "blocks",
|
||||
});
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
const relations = await services.issues.getRelations({ issueId: openIssueId, companyId });
|
||||
const edgeIds = [...relations.blockedBy, ...relations.blocks].map((edge) => edge.id);
|
||||
expect(edgeIds).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("reports a private root issue as not-found from issues.getSubtree", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issues.getSubtree({ issueId: privateIssueId, companyId }),
|
||||
).rejects.toThrow("Issue not found");
|
||||
});
|
||||
|
||||
it("excludes a private child and its documents from issues.getSubtree on an open root", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const rootId = randomUUID();
|
||||
const publicChildId = randomUUID();
|
||||
const privateChildId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" },
|
||||
{ id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" },
|
||||
{ id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" },
|
||||
]);
|
||||
// A document on the private child would surface in the subtree summary pre-fix.
|
||||
await seedIssueDocument(companyId, privateChildId, "plan", "confidential subtree plan");
|
||||
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
const subtree = await services.issues.getSubtree({
|
||||
issueId: rootId, companyId, includeDocuments: true, includeRelations: true,
|
||||
});
|
||||
expect(new Set(subtree.issueIds)).toEqual(new Set([rootId, publicChildId]));
|
||||
expect(subtree.issues.map((issue) => issue.id)).not.toContain(privateChildId);
|
||||
expect(Object.keys(subtree.documents ?? {})).not.toContain(privateChildId);
|
||||
});
|
||||
|
||||
it("returns no interactions from issues.listInteractions for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(), companyId, issueId: privateIssueId, kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Confirm confidential action?" } as any,
|
||||
});
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(services.issues.listInteractions({ issueId: privateIssueId, companyId })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("throws not-found from issueDocuments.list for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body");
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issueDocuments.list({ issueId: privateIssueId, companyId }),
|
||||
).rejects.toThrow("Issue not found");
|
||||
});
|
||||
|
||||
it("throws not-found from issueDocuments.get for a private issue", async () => {
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body");
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
await expect(
|
||||
services.issueDocuments.get({ issueId: privateIssueId, companyId, key: "plan" }),
|
||||
).rejects.toThrow("Issue not found");
|
||||
});
|
||||
|
||||
it("is pass-through when privacy mode is off (enforce flip is the single switch)", async () => {
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off";
|
||||
const { companyId, privateIssueId } = await seedOpenAndPrivate();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub());
|
||||
const ids = (await services.issues.list({ companyId })).map((issue) => issue.id);
|
||||
expect(ids).toContain(privateIssueId);
|
||||
await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toMatchObject({ id: privateIssueId });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { once } from "node:events";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import WebSocket from "ws";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { workspaceOperations, agents, approvals, assets, chatEndpoints, chatExternalPrincipals, chatConversations, chatIdentityLinks, chatDeliveries, toolApplications, toolConnections, executionWorkspaces, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, projectAccessMembers, projects, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { authorizationService, canPublishIssueToChatAudience, canActorReadIssuePrivacy, issueReadSqlCondition, canActorReadProjectPrivacy, projectReadSqlCondition, canActorReadApproval, approvalReadSqlCondition, canActorReadExecutionWorkspace, type AuthorizationActor } from "../services/authorization.js";
|
||||
|
||||
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
|
||||
|
||||
// Production regressions and the approved downward-only sharing contract.
|
||||
describe("private task production review", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-review-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
// Load the current route dependency graph during setup, outside assertion timeouts.
|
||||
await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]);
|
||||
}, 120_000);
|
||||
afterAll(async () => { await tempDb?.cleanup(); });
|
||||
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${randomUUID().slice(0, 5)}` }).returning();
|
||||
const owner = randomUUID(), outsider = randomUUID();
|
||||
await db.insert(authUsers).values([owner, outsider].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
||||
await db.insert(companyMemberships).values([owner, outsider].map(principalId => ({ companyId: company.id, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Shared agent", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: {} }).returning();
|
||||
const actor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session", companyIds: [company.id] });
|
||||
return { company, owner, outsider, agent, actor };
|
||||
}
|
||||
|
||||
it("lets only the project privacy owner or an admin change its audience", async () => {
|
||||
const f = await fixture();
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
let actor = f.actor(f.owner);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", projectRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const created = await request(app).post(`/api/companies/${f.company.id}/projects`).send({ name: "Private project", visibility: "private", privacyOwnerUserId: f.outsider });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.privacyOwnerUserId).toBe(f.owner);
|
||||
const projectId = created.body.id;
|
||||
const member = await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "user", subjectId: f.outsider });
|
||||
expect(member.status).toBe(201);
|
||||
actor = f.actor(f.outsider);
|
||||
expect((await request(app).get(`/api/projects/${projectId}`)).status).toBe(200);
|
||||
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(403);
|
||||
expect((await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "agent", subjectId: f.agent.id })).status).toBe(403);
|
||||
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(403);
|
||||
actor = f.actor(f.owner);
|
||||
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(200);
|
||||
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(200);
|
||||
await db.update(companyMemberships).set({ membershipRole: "admin" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.outsider)));
|
||||
actor = f.actor(f.outsider);
|
||||
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "private" })).status).toBe(200);
|
||||
});
|
||||
|
||||
it("publishes private chat content only to a currently authorized linked DM recipient", async () => {
|
||||
const f = await fixture();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private chat", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Test chat", type: "native" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: application.id, name: "Test chat", uid: "test-chat", connectionPurpose: "channel", transport: "chat_sdk" }).returning();
|
||||
const [endpoint] = await db.insert(chatEndpoints).values({ companyId: f.company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: f.agent.id }).returning();
|
||||
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: f.company.id, provider: "slack", providerAccountId: "test", externalId: "person", kind: "user" }).returning();
|
||||
const [conversation] = await db.insert(chatConversations).values({ companyId: f.company.id, endpointId: endpoint.id, issueId: task.id, externalConversationId: "dm", externalLabel: "Test DM", isDirectMessage: true }).returning();
|
||||
const publication = { companyId: f.company.id, issueId: task.id, endpointId: endpoint.id, conversationId: conversation.id };
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
||||
await db.insert(chatDeliveries).values({ companyId: f.company.id, endpointId: endpoint.id, conversationId: conversation.id, principalId: principal.id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "message", normalizedEvent: {} as any });
|
||||
const [link] = await db.insert(chatIdentityLinks).values({ companyId: f.company.id, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: f.outsider, status: "linked" }).returning();
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
||||
await db.update(chatConversations).set({ isDirectMessage: false }).where(eq(chatConversations.id, conversation.id));
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
||||
await db.update(chatConversations).set({ isDirectMessage: true }).where(eq(chatConversations.id, conversation.id));
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
||||
await db.update(chatIdentityLinks).set({ paperclipUserId: f.owner }).where(eq(chatIdentityLinks.id, link.id));
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
||||
await db.update(chatIdentityLinks).set({ revokedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id));
|
||||
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
||||
});
|
||||
|
||||
it("a shared agent cannot read an owner's private task on behalf of an unauthorized coworker", async () => {
|
||||
const f = await fixture();
|
||||
const id = randomUUID();
|
||||
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, assigneeAgentId: f.agent.id }).returning();
|
||||
const authz = authorizationService(db);
|
||||
const resource = { type: "issue" as const, companyId: f.company.id, issueId: id };
|
||||
expect((await authz.decide({ actor: f.actor(f.outsider), action: "issue:read", resource })).allowed).toBe(false);
|
||||
expect((await authz.decide({ actor: { type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_jwt", onBehalfOfUserId: f.outsider }, action: "issue:read", resource })).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("list SQL and direct checks agree after a project becomes open", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Previously private", visibility: "open" }).returning();
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
||||
const id = randomUUID();
|
||||
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Still individually private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), issue)).toBe(false);
|
||||
const found = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
|
||||
it("making a parent private also protects existing descendants", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner });
|
||||
const child = await svc.create(f.company.id, { title: "Existing child", parentId: parent.id, createdByUserId: f.owner });
|
||||
await svc.update(parent.id, { visibility: "private" });
|
||||
const updated = await svc.getById(child.id);
|
||||
expect(updated?.visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("restricting a project protects descendants outside it and preserves privacy when reopened", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const { projectService } = await import("../services/projects.js");
|
||||
const svc = issueService(db);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Project", visibility: "open" }).returning();
|
||||
const root = await svc.create(f.company.id, { title: "Root", projectId: project.id, createdByUserId: f.owner });
|
||||
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
||||
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
||||
await projectService(db).update(project.id, { visibility: "private" });
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
||||
await projectService(db).update(project.id, { visibility: "open" });
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
||||
expect((await svc.getById(child.id))?.visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("publishing a personal task leaves its private project while keeping its children private", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const root = await svc.create(f.company.id, { title: "Personal", visibility: "private", createdByUserId: f.owner });
|
||||
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
||||
const published = await svc.update(root.id, { visibility: "open" });
|
||||
expect(published?.projectId).toBeNull();
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), published!)).toBe(true);
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
||||
});
|
||||
|
||||
it("reparenting an open issue under a private task inherits privacy", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner, visibility: "private" });
|
||||
const child = await svc.create(f.company.id, { title: "Moved child", createdByUserId: f.owner });
|
||||
const updated = await svc.update(child.id, { parentId: parent.id });
|
||||
expect(updated?.visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("allows task management hints in an unreadable personal project without disclosing its identity", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Protected personal context", visibility: "private", personalOwnerUserId: f.outsider, privacyOwnerUserId: f.outsider }).returning();
|
||||
const id = randomUUID();
|
||||
const [task] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Owned task", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
||||
app.use("/api", issueRoutes(db, {} as any)); app.use("/api", projectRoutes(db));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
||||
await request(app).get(`/api/projects/${project.id}`).expect(404);
|
||||
const response = await request(app).get(`/api/issues/${task.id}/privacy-constraints`).expect(200);
|
||||
expect(response.body).toEqual({ publicBlockedBy: null, leavesPersonalProject: true });
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
expect(await issueService(db).update(task.id, { visibility: "open" })).toMatchObject({ visibility: "open", projectId: null });
|
||||
});
|
||||
|
||||
it("reports an inaccessible private parent as a move-first constraint without leaking it", async () => {
|
||||
const f = await fixture();
|
||||
const parentId = randomUUID(), childId = randomUUID();
|
||||
await db.insert(issues).values({ id: parentId, companyId: f.company.id, title: "Hidden parent content", visibility: "private", privacyRootIssueId: parentId, responsibleUserId: f.outsider });
|
||||
await db.insert(issues).values({ id: childId, companyId: f.company.id, title: "Owned child", visibility: "private", privacyRootIssueId: parentId, privacyParentIssueId: parentId, responsibleUserId: f.owner });
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
await request(app).get(`/api/issues/${parentId}`).expect(404);
|
||||
const response = await request(app).get(`/api/issues/${childId}/privacy-constraints`).expect(200);
|
||||
expect(response.body).toEqual({ publicBlockedBy: "parent", leavesPersonalProject: false });
|
||||
});
|
||||
|
||||
it("does not expose task management hints to a shared reader or unrelated company", async () => {
|
||||
const f = await fixture(); const id = randomUUID();
|
||||
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
let actor = f.actor(f.outsider);
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor as Express.Request["actor"]; next(); });
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
||||
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
||||
await db.insert(issueAccessGrants).values({ issueId: id, subjectType: "user", subjectId: f.outsider, source: "explicit" });
|
||||
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(403);
|
||||
actor = { ...f.actor(f.owner), companyIds: [] };
|
||||
await db.update(companyMemberships).set({ status: "archived" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.owner)));
|
||||
const denied = await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
||||
expect(denied.body.publicBlockedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an unauthorized board user cannot fetch a private issue through PATCH", async () => {
|
||||
const f = await fixture();
|
||||
const id = randomUUID();
|
||||
await db.insert(issues).values({ id, companyId: f.company.id, title: "Confidential acquisition", description: "Private email content", status: "backlog", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, createdByUserId: f.owner });
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = f.actor(f.outsider) as Express.Request["actor"]; next(); });
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
||||
await request(app).get(`/api/issues/${id}`).expect(404);
|
||||
const response = await request(app).patch(`/api/issues/${id}`).send({ priority: "low" });
|
||||
expect({ status: response.status, title: response.body.title }).toEqual({ status: 404, title: undefined });
|
||||
});
|
||||
|
||||
it("sharing a child grants its descendants, never its parent or sibling, and revokes immediately", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
||||
const child = await svc.create(f.company.id, { title: "Shared child", parentId: root.id, createdByUserId: f.owner });
|
||||
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
||||
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.owner });
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: child.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
||||
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), task)).toBe(expected);
|
||||
const rows = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, task.id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
||||
expect(rows.length > 0).toBe(expected);
|
||||
}
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
||||
});
|
||||
|
||||
it("binds child-manager grant writes to the child and rejects ancestor or sibling grants", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const svc = issueService(db);
|
||||
const root = await svc.create(f.company.id, { title: "Ancestor", visibility: "private", createdByUserId: f.outsider });
|
||||
const child = await svc.create(f.company.id, { title: "Managed child", parentId: root.id, createdByUserId: f.owner });
|
||||
const grandchild = await svc.create(f.company.id, { title: "Descendant", parentId: child.id, createdByUserId: f.outsider });
|
||||
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.outsider });
|
||||
const [rootGrant] = await db.insert(issueAccessGrants).values({ issueId: root.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
||||
const [siblingGrant] = await db.insert(issueAccessGrants).values({ issueId: sibling.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = f.actor(f.owner); next(); });
|
||||
app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler);
|
||||
const audience = await request(app).get(`/api/issues/${child.id}/access-grants`).expect(200);
|
||||
expect(audience.body).toContainEqual(expect.objectContaining({ id: rootGrant.id, inherited: true }));
|
||||
for (const grant of [rootGrant, siblingGrant]) {
|
||||
await request(app).post(`/api/issues/${child.id}/access-grants/${grant.id}/revoke`).send({}).expect(404);
|
||||
const [unchanged] = await db.select().from(issueAccessGrants).where(eq(issueAccessGrants.id, grant.id));
|
||||
expect(unchanged.revokedAt).toBeNull();
|
||||
}
|
||||
for (const task of [root, sibling]) {
|
||||
await request(app).post(`/api/issues/${task.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(403);
|
||||
}
|
||||
const added = await request(app).post(`/api/issues/${child.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(201);
|
||||
expect(added.body.issueId).toBe(child.id);
|
||||
const agent: AuthorizationActor = { type: "agent", companyId: f.company.id, agentId: f.agent.id };
|
||||
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
||||
expect(await canActorReadIssuePrivacy(db, agent, task)).toBe(expected);
|
||||
}
|
||||
await request(app).post(`/api/issues/${child.id}/access-grants/${added.body.id}/revoke`).send({}).expect(200);
|
||||
expect(await canActorReadIssuePrivacy(db, agent, child)).toBe(false);
|
||||
expect(await canActorReadIssuePrivacy(db, agent, grandchild)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains a child assignment grant after unassignment without granting the root", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
||||
const child = await svc.create(f.company.id, { title: "Assigned child", parentId: root.id, createdByUserId: f.owner, assigneeAgentId: f.agent.id });
|
||||
await svc.update(child.id, { assigneeAgentId: null });
|
||||
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id };
|
||||
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(true);
|
||||
expect(await canActorReadIssuePrivacy(db, actor, root)).toBe(false);
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, child.id));
|
||||
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(false);
|
||||
});
|
||||
|
||||
it("protects standalone tasks created from a private run", async () => {
|
||||
const f = await fixture();
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
const svc = issueService(db);
|
||||
const source = await svc.create(f.company.id, { title: "Private conversation", visibility: "private", createdByUserId: f.owner });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: source.id }).returning();
|
||||
const handoff = await svc.create(f.company.id, { title: "Standalone handoff", createdByUserId: f.owner, originRunId: run.id });
|
||||
expect(handoff).toMatchObject({ parentId: null, privacyParentIssueId: source.id, visibility: "private" });
|
||||
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), handoff)).toBe(false);
|
||||
});
|
||||
|
||||
it("intersects agent and responsible-user access to private projects in direct and list reads", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Private project", visibility: "private" }).returning();
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "agent", subjectId: f.agent.id });
|
||||
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id, onBehalfOfUserId: f.outsider };
|
||||
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(false);
|
||||
expect(await db.select().from(projects).where(and(eq(projects.id, project.id), await projectReadSqlCondition(db, actor)))).toEqual([]);
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
||||
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(true);
|
||||
});
|
||||
|
||||
it("binds native runs and keeps missing-task history fail closed", async () => {
|
||||
const f = await fixture();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning();
|
||||
expect(run).toMatchObject({ scopeKind: "issue", issueId: task.id });
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: {}, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id))).toMatchObject([{ scopeKind: "issue", issueId: task.id }]);
|
||||
const [orphan] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: randomUUID() } }).returning();
|
||||
expect(orphan).toMatchObject({ scopeKind: "issue", issueId: null });
|
||||
});
|
||||
|
||||
it("filters private and orphaned operation excerpts from a shared workspace listing", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Shared" }).returning();
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Shared workspace" }).returning();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, issueId: task.id, phase: "prepare", stdoutExcerpt: "PRIVATE_OPERATION" });
|
||||
const [publicOperation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, phase: "prepare", stdoutExcerpt: "PUBLIC_OPERATION" }).returning();
|
||||
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
let actor = f.actor(f.outsider);
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", executionWorkspaceRoutes(db)); app.use(errorHandler);
|
||||
const path = `/api/execution-workspaces/${workspace.id}/workspace-operations`;
|
||||
const outside = await request(app).get(path);
|
||||
expect(outside.status).toBe(200);
|
||||
expect(outside.body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
||||
actor = f.actor(f.owner);
|
||||
expect((await request(app).get(path)).body).toHaveLength(2);
|
||||
await db.delete(issues).where(eq(issues.id, task.id));
|
||||
expect((await request(app).get(path)).body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
||||
});
|
||||
|
||||
it("keeps operation history private even with a company run and after task or run deletion", async () => {
|
||||
const f = await fixture();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded" }).returning();
|
||||
expect(run.scopeKind).toBe("company");
|
||||
const [operation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, issueId: task.id, heartbeatRunId: run.id, phase: "prepare", stdoutExcerpt: "PRIVATE_LOG" }).returning();
|
||||
const access = authorizationService(db);
|
||||
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), operation)).toBe(true);
|
||||
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.outsider), operation)).toBe(false);
|
||||
await db.delete(issues).where(eq(issues.id, task.id));
|
||||
const [orphan] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, operation.id));
|
||||
expect(orphan).toMatchObject({ issueId: null, stdoutExcerpt: "PRIVATE_LOG", metadata: { _issuePrivacySources: { [task.id]: true } } });
|
||||
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), orphan)).toBe(false);
|
||||
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
||||
const [history] = await db.update(workspaceOperations).set({ metadata: {} }).where(eq(workspaceOperations.id, operation.id)).returning();
|
||||
expect(history).toMatchObject({ heartbeatRunId: null, metadata: { _runPrivacySources: { [run.id]: true }, _issuePrivacySources: { [task.id]: true } } });
|
||||
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), history)).toBe(false);
|
||||
});
|
||||
|
||||
it("delivers authorized live output, then stops delivery after revocation on the same socket", async () => {
|
||||
const f = await fixture();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning();
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
||||
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
||||
const { publishLiveEvent } = await import("../services/live-events.js");
|
||||
const server = createServer();
|
||||
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/${f.company.id}/events/ws`);
|
||||
const messages: string[] = [];
|
||||
socket.on("message", data => messages.push(data.toString()));
|
||||
try {
|
||||
await once(socket, "open");
|
||||
const publish = (chunk: string) => publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: task.id, runId: run.id, agentId: f.agent.id, chunk } });
|
||||
publish("AUTHORIZED_CANARY");
|
||||
await expect.poll(() => messages.some(message => message.includes("AUTHORIZED_CANARY"))).toBe(true);
|
||||
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "authorized_activity", entityType: "issue", entityId: task.id } });
|
||||
await expect.poll(() => messages.some(message => message.includes("authorized_activity"))).toBe(true);
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
publish("REVOKED_CANARY");
|
||||
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "revoked_activity", entityType: "issue", entityId: task.id } });
|
||||
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "queue_drained", entityType: "company", entityId: f.company.id } });
|
||||
await expect.poll(() => messages.some(message => message.includes("queue_drained"))).toBe(true);
|
||||
expect(messages.join(" ")).not.toContain("REVOKED_CANARY");
|
||||
expect(messages.join(" ")).not.toContain("revoked_activity");
|
||||
} finally {
|
||||
socket.terminate();
|
||||
await new Promise<void>(resolve => (wss as any).close(resolve));
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("protects linked approval payloads and execution workspace details", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Public project" }).returning();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, projectId: project.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [approval] = await db.insert(approvals).values({ companyId: f.company.id, type: "hire_agent", payload: { sourceIssueId: task.id, instructions: "PRIVATE_PAYLOAD" } }).returning();
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, sourceIssueId: task.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Private workspace" }).returning();
|
||||
expect(await canActorReadApproval(db, f.actor(f.owner), approval.id)).toBe(true);
|
||||
expect(await canActorReadApproval(db, f.actor(f.outsider), approval.id)).toBe(false);
|
||||
expect(await db.select().from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor(f.outsider))))).toEqual([]);
|
||||
expect(await canActorReadExecutionWorkspace(db, f.actor(f.owner), workspace.id)).toBe(true);
|
||||
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: null, metadata: {} }).where(eq(executionWorkspaces.id, workspace.id));
|
||||
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
||||
await db.delete(issues).where(eq(issues.id, task.id));
|
||||
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps inline draft images owner-only until publication then follows the task grant", async () => {
|
||||
const f = await fixture();
|
||||
const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/assets/issues/drafts/image.png`, contentType: "image/png", byteSize: 6, sha256: "test", createdByUserId: f.owner }).returning();
|
||||
const { assetRoutes } = await import("../routes/assets.js");
|
||||
const { issueService } = await import("../services/issues.js");
|
||||
function appFor(userId: string) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.actor = f.actor(userId) as any; next(); });
|
||||
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
||||
return app;
|
||||
}
|
||||
const url = `/api/assets/${asset.id}/content`;
|
||||
await request(appFor(f.owner)).get(url).expect(200);
|
||||
await request(appFor(f.outsider)).get(url).expect(404);
|
||||
const task = await issueService(db).create(f.company.id, { title: "Private draft", visibility: "private", createdByUserId: f.owner, description: `` });
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
||||
await request(appFor(f.outsider)).get(url).expect(200);
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
await request(appFor(f.outsider)).get(url).expect(404);
|
||||
await db.delete(issues).where(eq(issues.id, task.id));
|
||||
await request(appFor(f.outsider)).get(url).expect(404);
|
||||
});
|
||||
|
||||
it("protects attachment assets even after their attachment or task is deleted", async () => {
|
||||
const f = await fixture();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
||||
const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/issues/${task.id}/file.txt`, contentType: "text/plain", byteSize: 6, sha256: "test" }).returning();
|
||||
const { assetRoutes } = await import("../routes/assets.js");
|
||||
const appFor = (userId: string) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.actor = f.actor(userId) as Express.Request["actor"]; next(); });
|
||||
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
||||
return app;
|
||||
};
|
||||
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(200, "SECRET");
|
||||
await request(appFor(f.outsider)).get(`/api/assets/${asset.id}/content`).expect(404);
|
||||
await db.delete(issues).where(eq(issues.id, task.id));
|
||||
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(404);
|
||||
});
|
||||
|
||||
it("live WebSocket delivery does not disclose a private run to an unauthorized member", async () => {
|
||||
const f = await fixture();
|
||||
const id = randomUUID();
|
||||
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
||||
expect((await authorizationService(db).decide({ actor: f.actor(f.outsider), action: "issue:read", resource: { type: "issue", companyId: f.company.id, issueId: id } })).allowed).toBe(false);
|
||||
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
||||
const { publishLiveEvent } = await import("../services/live-events.js");
|
||||
const server = createServer();
|
||||
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/companies/${f.company.id}/events/ws`);
|
||||
try {
|
||||
await once(socket, "open");
|
||||
const message = once(socket, "message");
|
||||
publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: id, runId: randomUUID(), agentId: f.agent.id, chunk: "PRIVATE_EMAIL_CANARY" } });
|
||||
const result = await Promise.race([message.then(([data]) => data.toString()), new Promise<string>(resolve => setTimeout(() => resolve(""), 150))]);
|
||||
expect(result).not.toContain("PRIVATE_EMAIL_CANARY");
|
||||
} finally {
|
||||
socket.terminate();
|
||||
await new Promise<void>(resolve => (wss as any).close(resolve));
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { companies, createDb, projects } from "@paperclipai/db";
|
||||
import { companies, companyMemberships, createDb, projectAccessMembers, projects } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -51,9 +51,10 @@ describeEmbeddedPostgres("project list archived route defaults", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-projects-list-archived-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -108,3 +109,74 @@ describeEmbeddedPostgres("project list archived route defaults", () => {
|
||||
expect(res.body.map((project: { id: string }) => project.id)).toEqual([activeProjectId, archivedProjectId]);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("private project route visibility", { concurrent: false }, () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-project-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("omits private projects from non-member lists and returns 404 on direct fetch", async () => {
|
||||
const companyId = randomUUID();
|
||||
const privateProjectId = randomUUID();
|
||||
const memberUserId = "project-member";
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private projects",
|
||||
issuePrefix: `P${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(projects).values([
|
||||
{ companyId, name: "Open project", visibility: "open" },
|
||||
{ id: privateProjectId, companyId, name: "Secret project", visibility: "private" },
|
||||
]);
|
||||
await db.insert(companyMemberships).values([memberUserId, "project-outsider"].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
})));
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId,
|
||||
projectId: privateProjectId,
|
||||
subjectType: "user",
|
||||
subjectId: memberUserId,
|
||||
});
|
||||
const actorFor = (userId: string): Express.Request["actor"] => ({
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
|
||||
});
|
||||
|
||||
const outsiderApp = createApp(db, actorFor("project-outsider"));
|
||||
const outsiderList = await request(outsiderApp).get(`/api/companies/${companyId}/projects`);
|
||||
expect(outsiderList.status).toBe(200);
|
||||
expect(outsiderList.body.map((project: { id: string }) => project.id)).not.toContain(privateProjectId);
|
||||
await request(outsiderApp).get(`/api/projects/${privateProjectId}`).expect(404);
|
||||
|
||||
const memberApp = createApp(db, actorFor(memberUserId));
|
||||
const memberList = await request(memberApp).get(`/api/companies/${companyId}/projects`);
|
||||
expect(memberList.body.map((project: { id: string }) => project.id)).toContain(privateProjectId);
|
||||
await request(memberApp).get(`/api/projects/${privateProjectId}`).expect(200);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { projectRoutes } from "../routes/projects.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartbeatRuns } from "@paperclipai/db";
|
||||
import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartbeatRuns, issueAccessGrants } from "@paperclipai/db";
|
||||
import { createHmac, createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
@@ -657,6 +657,52 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
return { ...f, issue: issue!, call, dispatch, transfers, storage, cleanup: () => rm(storageDir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
async function makePrivate(f: Awaited<ReturnType<typeof expandedFixture>>) {
|
||||
const owner = randomUUID();
|
||||
await db.insert(authUsers).values({ id: owner, name: "Private owner", email: owner + "@example.com", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: owner, membershipRole: "member", status: "active" });
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issue.id, responsibleUserId: owner, createdByUserId: owner }).where(eq(issues.id, f.issue.id));
|
||||
return owner;
|
||||
}
|
||||
|
||||
it("hides private tasks from assistant search, reads, edits and file-link creation", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
await makePrivate(f);
|
||||
expect(await f.call("paperclip_search_tasks")).toMatchObject({ tasks: [] });
|
||||
for (const name of ["paperclip_read_task", "paperclip_list_deliverables", "paperclip_list_document_revisions"]) {
|
||||
await expect(f.call(name, { taskId: f.issue.id, ...(name === "paperclip_list_document_revisions" ? { key: "plan" } : {}) })).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
expect(await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes: { title: "Unauthorized edit" } })).toMatchObject({ outcome: "rejected", status: 404 });
|
||||
await expect(f.call("paperclip_get_upload_url", { taskId: f.issue.id, requestId: randomUUID(), filename: "data.txt", contentType: "text/plain", byteSize: 2, sha256: createHash("sha256").update("hi").digest("hex") })).rejects.toThrow();
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issue.id)))[0]?.title).toBe("Work");
|
||||
expect(await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.taskId, f.issue.id))).toHaveLength(0);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("revokes existing assistant upload and download links when private task sharing is removed", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const owner = await makePrivate(f);
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.actor.userId!, source: "explicit", grantedByUserId: owner }).returning();
|
||||
const bytes = Buffer.from("Private attachment");
|
||||
const args = { taskId: f.issue.id, filename: "private.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const uploadedUrl = new URL((await f.call("paperclip_get_upload_url", { ...args, requestId: randomUUID() })).url as string);
|
||||
const uploaded = await request(app).put(uploadedUrl.pathname + uploadedUrl.search).set("Content-Type", args.contentType).send(bytes);
|
||||
expect(uploaded.status).toBe(200);
|
||||
const download = new URL((await f.call("paperclip_get_download_url", { attachmentId: uploaded.body.attachment.id })).url as string);
|
||||
const pending = await f.call("paperclip_get_upload_url", { ...args, requestId: randomUUID() });
|
||||
const pendingUrl = new URL(pending.url as string);
|
||||
expect((await request(app).get(download.pathname + download.search)).status).toBe(200);
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant!.id));
|
||||
expect((await request(app).get(download.pathname + download.search)).status).not.toBe(200);
|
||||
expect((await request(app).put(pendingUrl.pathname + pendingUrl.search).set("Content-Type", args.contentType).send(bytes)).status).toBe(403);
|
||||
expect((await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, pending.uploadId as string)))[0]?.attachmentId).toBeNull();
|
||||
await expect(f.call("paperclip_get_download_url", { attachmentId: uploaded.body.attachment.id })).rejects.toThrow();
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("edits, blocks and completes tasks through real routes and rejects review overrides", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
@@ -52,6 +53,7 @@ function unprivilegedBoardActor(companyId: string): Express.Request["actor"] {
|
||||
source: "session",
|
||||
sessionId: "session-1",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "operator", status: "active" }],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
@@ -93,6 +95,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -322,6 +325,62 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
expect(list.body[0]).not.toHaveProperty("watchedIssueCount");
|
||||
});
|
||||
|
||||
it("redacts hydrated summaries and watched issues when a card includes a private task", async () => {
|
||||
const company = await seedCompany();
|
||||
await enableStatusCards();
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: privateIssueId,
|
||||
companyId: company.id,
|
||||
title: "Confidential acquisition",
|
||||
status: "blocked",
|
||||
priority: "high",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
});
|
||||
const document = await db.insert(documents).values({
|
||||
companyId: company.id,
|
||||
title: "Private status summary",
|
||||
format: "markdown",
|
||||
latestBody: "Confidential acquisition is blocked.",
|
||||
latestRevisionNumber: 1,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const card = await db.insert(statusCards).values({
|
||||
companyId: company.id,
|
||||
createdByUserId: "private-owner",
|
||||
interestPrompt: "Acquisition status",
|
||||
documentId: document.id,
|
||||
queries: [{ scope: "issues", status: ["blocked"], updatedWithin: "7d", sort: "updated", limit: 20, offset: 0 }],
|
||||
queryVersion: 1,
|
||||
refreshPolicy: { mode: "manual" },
|
||||
state: "active",
|
||||
fingerprint: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "unprivileged-user",
|
||||
membershipRole: "operator",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const app = createApp(db, unprivilegedBoardActor(company.id));
|
||||
const detail = await request(app).get(`/api/status-cards/${card.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toMatchObject({
|
||||
watchedIssueCount: 0,
|
||||
summaryBody: null,
|
||||
privacyRedacted: true,
|
||||
});
|
||||
expect(JSON.stringify(detail.body)).not.toContain("Confidential acquisition");
|
||||
|
||||
const dryRun = await request(app).get(`/api/status-cards/${card.id}/dry-run`);
|
||||
expect(dryRun.status).toBe(200);
|
||||
expect(JSON.stringify(dryRun.body)).not.toContain("Confidential acquisition");
|
||||
expect(JSON.stringify(dryRun.body)).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("refreshes a card whose watched issues carry human comments", async () => {
|
||||
// Regression: the postgres-js driver returns `max(updated_at)` as a string,
|
||||
// so `latestHumanCommentAt.toISOString()` threw and refresh 500'd whenever a
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Isolate runtime-management guards; real database privacy is covered by
|
||||
// privacy-production-review.test.ts.
|
||||
vi.mock("../services/authorization.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../services/authorization.js")>("../services/authorization.js")),
|
||||
executionWorkspaceReadSqlCondition: async () => sql<boolean>`true`,
|
||||
canActorReadExecutionWorkspace: async () => true,
|
||||
}));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createRequire } from "node:module";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import type { DeploymentMode, LiveEvent } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
@@ -40,7 +40,12 @@ const { WebSocket, WebSocketServer } = require("ws") as {
|
||||
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||
};
|
||||
|
||||
import { authorizationService, type AuthorizationActor } from "../services/authorization.js";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
|
||||
interface UpgradeContext {
|
||||
actor: AuthorizationActor;
|
||||
apiKeyId?: string;
|
||||
companyId: string;
|
||||
actorType: "board" | "agent";
|
||||
actorId: string;
|
||||
@@ -143,6 +148,7 @@ async function authorizeUpgrade(
|
||||
companyId,
|
||||
actorType: "board",
|
||||
actorId: "board",
|
||||
actor: { type: "board", userId: "local-board", source: "local_implicit" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,6 +167,7 @@ async function authorizeUpgrade(
|
||||
companyId,
|
||||
actorType: "board",
|
||||
actorId: cloudActor.userId,
|
||||
actor: { type: "board", userId: cloudActor.userId, source: "cloud_tenant", companyIds: cloudActor.companyIds },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -198,6 +205,7 @@ async function authorizeUpgrade(
|
||||
companyId,
|
||||
actorType: "board",
|
||||
actorId: userId,
|
||||
actor: { type: "board", userId, source: "session", companyIds: memberships.map(m => m.companyId) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,7 +216,7 @@ async function authorizeUpgrade(
|
||||
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!key || key.companyId !== companyId) {
|
||||
if (!key || key.companyId !== companyId || key.scopeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,6 +229,8 @@ async function authorizeUpgrade(
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: key.agentId,
|
||||
apiKeyId: key.id,
|
||||
actor: { type: "agent", agentId: key.agentId, companyId, source: "agent_key", onBehalfOfUserId: key.responsibleUserId },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,9 +270,67 @@ export function setupLiveEventsWebSocketServer(
|
||||
return;
|
||||
}
|
||||
|
||||
const access = authorizationService(db);
|
||||
async function visibleEvent(event: LiveEvent): Promise<LiveEvent | null> {
|
||||
if (context!.actor.source === "local_implicit") return event;
|
||||
if (context!.apiKeyId) {
|
||||
const active = await db.select({ id: agentApiKeys.id }).from(agentApiKeys)
|
||||
.where(and(eq(agentApiKeys.id, context!.apiKeyId), isNull(agentApiKeys.revokedAt))).limit(1);
|
||||
if (!active.length) return null;
|
||||
} else {
|
||||
const membership = await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, context!.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, context!.actorId), eq(companyMemberships.status, "active"))).limit(1);
|
||||
const admin = context!.actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, context!.actorId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
|
||||
if (!membership.length && !admin) return null;
|
||||
}
|
||||
const payload = event.payload;
|
||||
const issueId = typeof payload.issueId === "string" ? payload.issueId
|
||||
: payload.entityType === "issue" && typeof payload.entityId === "string" ? payload.entityId : null;
|
||||
if (issueId) {
|
||||
const [issue] = await db.select({ id: issues.id }).from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, event.companyId))).limit(1);
|
||||
if (!issue || !(await access.decide({ actor: context!.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null;
|
||||
}
|
||||
if (payload.entityType === "project" && typeof payload.entityId === "string") {
|
||||
const [project] = await db.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, payload.entityId), eq(projects.companyId, event.companyId))).limit(1);
|
||||
if (!project || !(await access.decide({ actor: context!.actor, action: "project:read",
|
||||
resource: { type: "project", companyId: event.companyId, projectId: project.id } })).allowed) return null;
|
||||
}
|
||||
const runId = typeof payload.runId === "string" ? payload.runId
|
||||
: payload.entityType === "heartbeat_run" && typeof payload.entityId === "string" ? payload.entityId : null;
|
||||
if (runId) {
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, event.companyId)));
|
||||
if (!run || !(await canActorReadHeartbeatRun(db, access, context!.actor, run))) return null;
|
||||
}
|
||||
if (event.type.startsWith("heartbeat.run.")) return runId ? event : null;
|
||||
if (event.type === "agent.session.goal.changed") return issueId ? event : null;
|
||||
// Company-wide invalidations carry no task-derived content. Authorized
|
||||
// clients obtain details through viewer-filtered HTTP reads.
|
||||
if (event.type === "activity.logged") return { ...event, payload: {
|
||||
action: payload.action, entityType: payload.entityType, entityId: payload.entityId,
|
||||
} };
|
||||
if (event.type === "agent.status") return { ...event, payload: { agentId: payload.agentId, status: payload.status } };
|
||||
if (event.type === "external_object.updated") return { ...event, payload: { externalObjectId: payload.externalObjectId } };
|
||||
if (event.type.startsWith("plugin.")) return event;
|
||||
return null;
|
||||
}
|
||||
let delivery = Promise.resolve();
|
||||
let pending = 0;
|
||||
const unsubscribe = subscribeCompanyLiveEvents(context.companyId, (event) => {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify(event));
|
||||
if (++pending > 1000) { socket.close(1013, "event backlog"); return; }
|
||||
delivery = delivery.then(async () => {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
// Check at delivery time, including after a grant is revoked while the
|
||||
// subscription remains open. Never cache successful privacy decisions.
|
||||
const visible = await visibleEvent(event);
|
||||
if (visible && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(visible));
|
||||
}).catch(err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed"))
|
||||
.finally(() => { pending -= 1; });
|
||||
});
|
||||
|
||||
cleanupByClient.set(socket, unsubscribe);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { sanitizeRecord } from "../redaction.js";
|
||||
import { badRequest, forbidden } from "../errors.js";
|
||||
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
|
||||
/** Max rows a single CSV export will stream (guards against runaway exports). */
|
||||
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
|
||||
@@ -230,6 +232,7 @@ export function activityRoutes(db: Db) {
|
||||
entityType: req.query.entityType as string | undefined,
|
||||
entityId: req.query.entityId as string | undefined,
|
||||
limit: normalizeActivityLimit(Number(req.query.limit)),
|
||||
readCondition: await issueReadSqlCondition(db, req.actor),
|
||||
};
|
||||
const result = await svc.list(filters);
|
||||
res.json(result);
|
||||
@@ -369,6 +372,10 @@ export function activityRoutes(db: Db) {
|
||||
}
|
||||
assertCompanyAccess(req, run.companyId);
|
||||
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const result = await svc.issuesForRun(runId);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -261,6 +261,11 @@ import {
|
||||
changeConsentGateService,
|
||||
touchesAgentProfileChangeConsentFields,
|
||||
} from "../services/change-consent-gate.js";
|
||||
import {
|
||||
canActorReadHeartbeatRun,
|
||||
canActorReadWorkspaceOperation,
|
||||
redactHeartbeatRunListRow,
|
||||
} from "../services/heartbeat-run-privacy.js";
|
||||
import {
|
||||
PaperclipRunnerProviderProfileError,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
@@ -300,6 +305,12 @@ function mergeDesiredSkillEntries(
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
type RunReadBinding = {
|
||||
companyId: string;
|
||||
scopeKind?: "company" | "issue" | null;
|
||||
issueId?: string | null;
|
||||
};
|
||||
|
||||
function readRunLogLimitBytes(value: unknown) {
|
||||
const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES);
|
||||
if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES;
|
||||
@@ -759,6 +770,27 @@ export function agentRoutes(
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
|
||||
async function actorCanReadRun(req: Request, run: RunReadBinding) {
|
||||
return canActorReadHeartbeatRun(db, access, req.actor, run);
|
||||
}
|
||||
|
||||
async function assertRunReadAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
run: RunReadBinding,
|
||||
notFoundMessage = "Heartbeat run not found",
|
||||
) {
|
||||
if (await actorCanReadRun(req, run)) return true;
|
||||
res.status(404).json({ error: notFoundMessage });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function serializeRunListRow<
|
||||
T extends Record<string, unknown> & RunReadBinding & { id: string },
|
||||
>(req: Request, run: T) {
|
||||
return await actorCanReadRun(req, run) ? run : redactHeartbeatRunListRow(run);
|
||||
}
|
||||
|
||||
// The company-scoped adapter login-session service. It runs the device-login
|
||||
// flow in a fresh trusted sandbox and holds the one-time prompt in memory. The
|
||||
// process owns one instance, so the in-memory prompt and the cancellation
|
||||
@@ -7030,7 +7062,7 @@ export function agentRoutes(
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
|
||||
const summary = req.query.summary === "true" || req.query.summary === "1";
|
||||
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
|
||||
res.json(await runRedactions.redactForRuns(companyId, runs));
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(runs.map(run => serializeRunListRow(req, run)))));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/provider-traces", async (req, res) => {
|
||||
@@ -7103,7 +7135,17 @@ export function agentRoutes(
|
||||
lastOutputStream: heartbeatRuns.lastOutputStream,
|
||||
lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
||||
processStartedAt: heartbeatRuns.processStartedAt,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
scopeKind: heartbeatRuns.scopeKind,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
inputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
|
||||
cachedInputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"),
|
||||
outputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"),
|
||||
totalTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"),
|
||||
costUsd: sql<number | null>`coalesce(
|
||||
(${heartbeatRuns.resultJson} ->> 'costUsd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric
|
||||
)`.as("costUsd"),
|
||||
};
|
||||
|
||||
const liveRunsQuery = db
|
||||
@@ -7156,7 +7198,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => !(await actorCanReadRun(req, run)) ? redactHeartbeatRunListRow(run) : ({
|
||||
...heartbeat.decorateActiveRunStatus(run),
|
||||
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
|
||||
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
|
||||
@@ -7179,6 +7221,7 @@ export function agentRoutes(
|
||||
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!run) return;
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await assertRunReadAllowed(req, res, run))) return;
|
||||
const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId);
|
||||
const decoratedRun = heartbeat.decorateActiveRunStatus(run);
|
||||
res.json(await runRedactions.redactForRun(
|
||||
@@ -7196,6 +7239,7 @@ export function agentRoutes(
|
||||
const runId = readHeartbeatRunId(req);
|
||||
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertRunReadAllowed(req, res, existing))) return;
|
||||
const requestId = cancellationRequestId(req.body?.cancellationRequestId);
|
||||
// Stamp the cancellation as operator-initiated (this route is board-only).
|
||||
// Recovery reads this to stand down instead of classifying the cancelled
|
||||
@@ -7435,6 +7479,7 @@ export function agentRoutes(
|
||||
const runId = readHeartbeatRunId(req);
|
||||
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertRunReadAllowed(req, res, existing))) return;
|
||||
const decision = typeof req.body?.decision === "string" ? req.body.decision : "";
|
||||
if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) {
|
||||
res.status(400).json({ error: "Unsupported watchdog decision" });
|
||||
@@ -7655,6 +7700,7 @@ export function agentRoutes(
|
||||
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!run) return;
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await assertRunReadAllowed(req, res, run))) return;
|
||||
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
@@ -7674,6 +7720,7 @@ export function agentRoutes(
|
||||
const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found");
|
||||
if (!run) return;
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await assertRunReadAllowed(req, res, run))) return;
|
||||
|
||||
const offset = Number(req.query.offset ?? 0);
|
||||
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
|
||||
@@ -7691,11 +7738,16 @@ export function agentRoutes(
|
||||
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!run) return;
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await assertRunReadAllowed(req, res, run))) return;
|
||||
|
||||
const context = asRecord(run.contextSnapshot);
|
||||
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
|
||||
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
|
||||
res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions()));
|
||||
const visibleOperations = [];
|
||||
for (const operation of operations) {
|
||||
if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation);
|
||||
}
|
||||
res.json(redactCurrentUserValue(visibleOperations, await getCurrentUserRedactionOptions()));
|
||||
});
|
||||
|
||||
router.get("/workspace-operations/:operationId/log", async (req, res) => {
|
||||
@@ -7703,6 +7755,10 @@ export function agentRoutes(
|
||||
const operation = await getAccessibleResource(req, res, workspaceOperations.getById(operationId), "Workspace operation not found");
|
||||
if (!operation) return;
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, operation.companyId))) return;
|
||||
if (!(await canActorReadWorkspaceOperation(db, access, req.actor, operation))) {
|
||||
res.status(404).json({ error: "Workspace operation not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = Number(req.query.offset ?? 0);
|
||||
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
|
||||
@@ -7761,7 +7817,7 @@ export function agentRoutes(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
|
||||
eq(heartbeatRuns.issueId, issue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
|
||||
@@ -22,6 +22,7 @@ import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo,
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { approvalReadSqlCondition, canActorReadApproval } from "../services/authorization.js";
|
||||
import { REVIEW_PATH_RECOVERY_INSTRUCTION } from "../services/recovery/review-path-recovery.js";
|
||||
|
||||
function redactApprovalPayload<T extends { payload: Record<string, unknown> }>(approval: T): T {
|
||||
@@ -45,6 +46,15 @@ export function approvalRoutes(
|
||||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
||||
) {
|
||||
const router = Router();
|
||||
router.param("id", async (req, res, next, id) => {
|
||||
try {
|
||||
if (!(await canActorReadApproval(db, req.actor, id))) {
|
||||
res.status(404).json({ error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
const svc = approvalService(db);
|
||||
const access = accessService(db);
|
||||
const heartbeat = heartbeatService(db, {
|
||||
@@ -207,7 +217,7 @@ export function approvalRoutes(
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (!(await assertApprovalAccessAllowed(req, res, companyId))) return;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await svc.list(companyId, status);
|
||||
const result = await svc.list(companyId, status, await approvalReadSqlCondition(db, req.actor));
|
||||
res.json(result.map((approval) => redactApprovalPayload(approval)));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { runnerApiResponseReservations, heartbeatRuns, issues, issueAttachments } from "@paperclipai/db";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import { ASSET_NAMESPACE_RULE, isUuidLike, createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import {
|
||||
@@ -184,7 +188,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : req.actor.onBehalfOfUserId ?? null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
@@ -198,7 +202,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
entityType: "asset",
|
||||
entityId: asset.id,
|
||||
details: {
|
||||
originalFilename: asset.originalFilename,
|
||||
...(namespaceSuffix.startsWith("issues/") ? {} : { originalFilename: asset.originalFilename }),
|
||||
contentType: asset.contentType,
|
||||
byteSize: asset.byteSize,
|
||||
},
|
||||
@@ -327,6 +331,41 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
const assetId = req.params.assetId as string;
|
||||
const asset = await getAccessibleResource(req, res, svc.getById(assetId), "Asset not found");
|
||||
if (!asset) return;
|
||||
const access = authorizationService(db);
|
||||
// Persisted storage namespaces retain provenance after attachment rows are deleted.
|
||||
const objectParts = asset.objectKey.split("/");
|
||||
const isDraftImage = objectParts[1] === "assets" && objectParts[2] === "issues" && objectParts[3] === "drafts";
|
||||
const namespace = objectParts[1] === "assets" ? objectParts[2] : objectParts[1];
|
||||
const sourceIssueId = objectParts[1] === "assets" ? objectParts[3] : objectParts[2];
|
||||
const sourceExists = namespace !== "issues" || isDraftImage || (sourceIssueId && isUuidLike(sourceIssueId) && await db.select({ id: issues.id }).from(issues)
|
||||
.where(and(eq(issues.id, sourceIssueId), eq(issues.companyId, asset.companyId))).limit(1).then(rows => rows.length > 0));
|
||||
if (namespace === "issues" && !isDraftImage && (!sourceExists || !(await access.decide({ actor: req.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: asset.companyId, issueId: sourceIssueId } })).allowed)) {
|
||||
res.status(404).json({ error: "Asset not found" }); return;
|
||||
}
|
||||
const captures = await db.select({ run: heartbeatRuns }).from(runnerApiResponseReservations)
|
||||
.leftJoin(heartbeatRuns, eq(heartbeatRuns.id, runnerApiResponseReservations.runId))
|
||||
.where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId)));
|
||||
if (namespace === "runner-api" && !captures.length) { res.status(404).json({ error: "Asset not found" }); return; }
|
||||
for (const { run } of captures) {
|
||||
if (!run || !(await canActorReadHeartbeatRun(db, access, req.actor, run))) {
|
||||
res.status(404).json({ error: "Asset not found" }); return;
|
||||
}
|
||||
}
|
||||
const attachments = await db.select({ issueId: issueAttachments.issueId }).from(issueAttachments)
|
||||
.where(and(eq(issueAttachments.assetId, asset.id), eq(issueAttachments.companyId, asset.companyId)));
|
||||
if (isDraftImage && !attachments.length && !(req.actor.type === "board"
|
||||
? req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || req.actor.userId === asset.createdByUserId
|
||||
: req.actor.type === "agent" && req.actor.agentId === asset.createdByAgentId
|
||||
&& (!asset.createdByUserId || req.actor.onBehalfOfUserId === asset.createdByUserId))) {
|
||||
res.status(404).json({ error: "Asset not found" }); return;
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
if (!(await access.decide({ actor: req.actor, action: "issue:read", resource: {
|
||||
type: "issue", companyId: asset.companyId, issueId: attachment.issueId,
|
||||
} })).allowed) { res.status(404).json({ error: "Asset not found" }); return; }
|
||||
}
|
||||
|
||||
|
||||
// Use the persisted size only after resource authorization. Single ranges
|
||||
// keep saved API text pages bounded all the way to disk or object storage.
|
||||
@@ -354,7 +393,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
res.status(206);
|
||||
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${asset.byteSize}`);
|
||||
}
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (!inlineSafe) {
|
||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
|
||||
@@ -41,6 +41,7 @@ export function attentionRoutes(db: Db) {
|
||||
const feed = await svc.list(companyId, {
|
||||
userId: req.actor.userId,
|
||||
includeDismissed,
|
||||
actor: req.actor,
|
||||
archived,
|
||||
all,
|
||||
allowUnscopedAll: all,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { authorizationService, issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { decisionTrainingService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
@@ -52,6 +53,14 @@ function requireExampleOwner(res: Response, userId: string, createdByUserId: str
|
||||
export function decisionTrainingRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = decisionTrainingService(db);
|
||||
async function canRead(req: Request, companyId: string, issueId: string) {
|
||||
return (await authorizationService(db).decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId, issueId } })).allowed;
|
||||
}
|
||||
async function requireRead(req: Request, res: Response, companyId: string, issueId: string) {
|
||||
if (await canRead(req, companyId, issueId)) return true;
|
||||
res.status(404).json({ error: "Decision training source not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/decision-training",
|
||||
@@ -61,6 +70,7 @@ export function decisionTrainingRoutes(db: Db) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireHumanUser(req, res);
|
||||
if (!userId) return;
|
||||
if (!(await requireRead(req, res, companyId, req.body.issueId))) return;
|
||||
|
||||
const example = await svc.create({
|
||||
companyId,
|
||||
@@ -97,6 +107,7 @@ export function decisionTrainingRoutes(db: Db) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireHumanUser(req, res);
|
||||
if (!userId) return;
|
||||
if (!(await requireRead(req, res, companyId, req.body.issueId))) return;
|
||||
const preview = await svc.preview({
|
||||
companyId,
|
||||
sourceKind: req.body.sourceKind,
|
||||
@@ -126,6 +137,7 @@ export function decisionTrainingRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
res.json(await svc.list(companyId, {
|
||||
readCondition: await issueReadSqlCondition(db, req.actor),
|
||||
projectId: parsed.data.project,
|
||||
kind: parsed.data.kind,
|
||||
author: parsed.data.author,
|
||||
@@ -137,7 +149,7 @@ export function decisionTrainingRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
const rows = await svc.list(companyId);
|
||||
const rows = await svc.list(companyId, { readCondition: await issueReadSqlCondition(db, req.actor) });
|
||||
const body = rows
|
||||
.map(({ example }) => JSON.stringify({
|
||||
retentionPolicy: example.retentionPolicy,
|
||||
@@ -165,7 +177,7 @@ export function decisionTrainingRoutes(db: Db) {
|
||||
const exampleId = parseExampleId(req, res);
|
||||
if (!exampleId) return;
|
||||
const example = await svc.getById(exampleId);
|
||||
if (!example || !hasCompanyAccess(req, example.companyId)) {
|
||||
if (!example || !hasCompanyAccess(req, example.companyId) || !(await canRead(req, example.companyId, example.issueId))) {
|
||||
res.status(404).json({ error: "Decision training example not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
|
||||
import { canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition } from "../services/authorization.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -96,6 +98,12 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
|
||||
async function assertWorkspacePrivacy(req: Request, res: Response, id: string) {
|
||||
if (await canActorReadExecutionWorkspace(db, req.actor, id)) return true;
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertExecutionWorkspaceReadAllowed(req: Request, res: Response, companyId: string) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
@@ -123,6 +131,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (!(await assertExecutionWorkspaceReadAllowed(req, res, companyId))) return;
|
||||
const filters = {
|
||||
readCondition: await executionWorkspaceReadSqlCondition(db, req.actor),
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
||||
issueId: req.query.issueId as string | undefined,
|
||||
@@ -149,7 +158,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
return;
|
||||
}
|
||||
|
||||
const overview = await svc.listOverview(companyId, parsed.data);
|
||||
const overview = await svc.listOverview(companyId, parsed.data, await executionWorkspaceReadSqlCondition(db, req.actor));
|
||||
res.json(overview);
|
||||
});
|
||||
|
||||
@@ -157,6 +166,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
const id = req.params.id as string;
|
||||
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!workspace) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return;
|
||||
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
|
||||
res.json(workspace);
|
||||
});
|
||||
@@ -165,6 +175,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
const id = req.params.id as string;
|
||||
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!workspace) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return;
|
||||
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
|
||||
const readiness = await svc.getCloseReadiness(id);
|
||||
if (!readiness) {
|
||||
@@ -187,6 +198,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
assertBoard(req);
|
||||
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!workspace) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return;
|
||||
// Opening a workspace board is a runtime-control-grade action: it hands the
|
||||
// caller an authenticated session inside the cloned instance.
|
||||
if (!(await assertRuntimeManageAllowed(req, res, workspace.companyId))) return;
|
||||
@@ -259,9 +271,14 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
const id = req.params.id as string;
|
||||
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!workspace) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, workspace.id))) return;
|
||||
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
|
||||
const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id);
|
||||
res.json(operations);
|
||||
const visibleOperations = [];
|
||||
for (const operation of operations) {
|
||||
if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation);
|
||||
}
|
||||
res.json(visibleOperations);
|
||||
});
|
||||
|
||||
async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) {
|
||||
@@ -274,6 +291,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, existing.id))) return;
|
||||
if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return;
|
||||
|
||||
const authorization = await assertCanManageExecutionWorkspaceRuntimeServices(db, req, {
|
||||
@@ -1050,6 +1068,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, existing.id))) return;
|
||||
assertBoard(req);
|
||||
if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return;
|
||||
|
||||
@@ -1139,6 +1158,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertWorkspacePrivacy(req, res, existing.id))) return;
|
||||
if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
|
||||
@@ -355,7 +355,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
availabilityLimiter?: FileResourceLimiter;
|
||||
} = {}) {
|
||||
const router = Router();
|
||||
const svc = opts.service ?? workspaceFileResourceService(db);
|
||||
const serviceFor = (req: Request) => opts.service ?? workspaceFileResourceService(db, req.actor);
|
||||
const limiter = opts.limiter ?? createFileResourceLimiter();
|
||||
const listLimiter = opts.listLimiter ?? createFileResourceListLimiter();
|
||||
const availabilityLimiter = opts.availabilityLimiter ?? createFileResourceAvailabilityLimiter();
|
||||
@@ -468,7 +468,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const issue = await serviceFor(req).getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
if (!hasCompanyAccess(req, issue.companyId)) {
|
||||
const error = notFound("Issue not found");
|
||||
@@ -501,7 +501,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
let release: (() => void) | null = null;
|
||||
try {
|
||||
release = availabilityLimiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
|
||||
const result = await svc.availability(req.params.issueId, body, { issue });
|
||||
const result = await serviceFor(req).availability(req.params.issueId, body, { issue });
|
||||
await logAvailabilityAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
@@ -544,7 +544,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const issue = await serviceFor(req).getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
if (!hasCompanyAccess(req, issue.companyId)) {
|
||||
@@ -595,7 +595,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
|
||||
const requestAbort = requestAbortController(req, res);
|
||||
try {
|
||||
const result = await svc.list(req.params.issueId, query, {
|
||||
const result = await serviceFor(req).list(req.params.issueId, query, {
|
||||
issue,
|
||||
scanContext: {
|
||||
signal: requestAbort.signal,
|
||||
@@ -677,7 +677,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const issue = await serviceFor(req).getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
if (!hasCompanyAccess(req, issue.companyId)) {
|
||||
@@ -730,7 +730,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = await svc.resolve(req.params.issueId, query, { issue });
|
||||
const result = await serviceFor(req).resolve(req.params.issueId, query, { issue });
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -789,7 +789,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const issue = await serviceFor(req).getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
if (!hasCompanyAccess(req, issue.companyId)) {
|
||||
@@ -842,7 +842,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
if (parseBooleanQuery(req.query.download)) {
|
||||
let result: Awaited<ReturnType<WorkspaceFileResourceService["prepareDownload"]>> | null = null;
|
||||
try {
|
||||
result = await svc.prepareDownload(req.params.issueId, query, { issue });
|
||||
result = await serviceFor(req).prepareDownload(req.params.issueId, query, { issue });
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
@@ -892,7 +892,7 @@ export function fileResourceRoutes(db: Db, opts: {
|
||||
|
||||
let result: WorkspaceFileContent | null = null;
|
||||
try {
|
||||
result = await svc.readContent(req.params.issueId, query, { issue });
|
||||
result = await serviceFor(req).readContent(req.params.issueId, query, { issue });
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
issues as issueRows,
|
||||
type Db,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
heartbeatService,
|
||||
issueService,
|
||||
issueTreeControlService,
|
||||
@@ -55,6 +56,35 @@ export function issueTreeControlRoutes(
|
||||
const heartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const access = accessService(db);
|
||||
|
||||
async function assertIssueReadAllowed(req: Request, res: Response, issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
status?: string;
|
||||
}) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId ?? null,
|
||||
parentIssueId: issue.parentId ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
status: issue.status ?? "backlog",
|
||||
},
|
||||
});
|
||||
if (decision.allowed) return true;
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRootIssue(req: Request) {
|
||||
const rootIssueId = req.params.id as string;
|
||||
@@ -66,6 +96,7 @@ export function issueTreeControlRoutes(
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const preview = await treeControlSvc.preview(root.companyId, root.id, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
@@ -93,6 +124,7 @@ export function issueTreeControlRoutes(
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const actorInput = {
|
||||
@@ -334,6 +366,7 @@ export function issueTreeControlRoutes(
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await getAccessibleResource(req, res, issuesSvc.getById(issueId), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
|
||||
res.json({ activePauseHold });
|
||||
});
|
||||
@@ -342,6 +375,7 @@ export function issueTreeControlRoutes(
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
const statusParam = typeof req.query.status === "string" ? req.query.status : null;
|
||||
const modeParam = typeof req.query.mode === "string" ? req.query.mode : null;
|
||||
const includeMembers = req.query.includeMembers === "true";
|
||||
@@ -360,6 +394,7 @@ export function issueTreeControlRoutes(
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const holdId = req.params.holdId as string;
|
||||
if (!isUuidLike(holdId)) {
|
||||
@@ -387,6 +422,7 @@ export function issueTreeControlRoutes(
|
||||
"Root issue not found",
|
||||
);
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const holdId = req.params.holdId as string;
|
||||
if (!isUuidLike(holdId)) {
|
||||
|
||||
+686
-79
File diff suppressed because it is too large.
Load diff
@@ -70,6 +70,7 @@ import {
|
||||
// Project
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
// Company
|
||||
@@ -1408,6 +1409,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
||||
"PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}",
|
||||
"PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}",
|
||||
"PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}",
|
||||
"POST /api/projects/{id}/access-members",
|
||||
"DELETE /api/projects/{id}/access-members/{memberId}",
|
||||
"GET /api/companies/{companyId}/secret-provider-configs",
|
||||
"POST /api/companies/{companyId}/secret-provider-configs",
|
||||
"GET /api/companies/{companyId}/secret-providers/health",
|
||||
@@ -1642,6 +1645,7 @@ const CREATED_OPERATIONS = new Set([
|
||||
"POST /api/issues/{id}/comments",
|
||||
"POST /api/companies/{companyId}/issues/{issueId}/attachments",
|
||||
"POST /api/companies/{companyId}/projects",
|
||||
"POST /api/projects/{id}/access-members",
|
||||
"POST /api/projects/{id}/workspaces",
|
||||
"POST /api/companies/{companyId}/routines",
|
||||
"POST /api/companies/{companyId}/folders",
|
||||
@@ -1960,6 +1964,28 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/access-grants",
|
||||
tags: ["issues"],
|
||||
summary: "Grant a user or agent access to a private issue subtree",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(z.object({
|
||||
subjectType: z.enum(["user", "agent"]),
|
||||
subjectId: z.string().min(1),
|
||||
}).strict()),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
201: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/openapi.json",
|
||||
@@ -4323,6 +4349,52 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/privacy-constraints",
|
||||
tags: ["issues"],
|
||||
summary: "Get task privacy action constraints for an authorized manager",
|
||||
description: "Returns action hints without disclosing protected parent or project identity. Visibility writes recheck the current rules under the privacy-tree lock.",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(z.object({
|
||||
publicBlockedBy: z.enum(["parent", "project"]).nullable(),
|
||||
leavesPersonalProject: z.boolean(),
|
||||
}).strict()),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/access-grants",
|
||||
tags: ["issues"],
|
||||
summary: "List issue access grants",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/access-grants/{grantId}/revoke",
|
||||
tags: ["issues"],
|
||||
summary: "Revoke an issue access grant",
|
||||
request: { params: z.object({ id: z.string(), grantId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/approvals",
|
||||
@@ -4694,6 +4766,49 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/projects/{id}/access-members",
|
||||
tags: ["projects"],
|
||||
summary: "List project access members",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/projects/{id}/access-members",
|
||||
tags: ["projects"],
|
||||
summary: "Add a project access member",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(addProjectAccessMemberSchema),
|
||||
},
|
||||
responses: {
|
||||
201: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/projects/{id}/access-members/{memberId}",
|
||||
tags: ["projects"],
|
||||
summary: "Remove a project access member",
|
||||
request: { params: z.object({ id: z.string(), memberId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/projects/{id}/workspaces",
|
||||
|
||||
@@ -8,14 +8,19 @@ import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import {
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectSchema,
|
||||
projectDiscoverySchema,
|
||||
type ProjectDiscoveryPage,
|
||||
createProjectWorkspaceSchema,
|
||||
deriveProjectUrlKey,
|
||||
findWorkspaceCommandDefinition,
|
||||
isUuidLike,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
normalizeProjectUrlKey,
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
@@ -28,6 +33,7 @@ import { conflict, forbidden, unprocessable } from "../errors.js";
|
||||
import { externalObjectService } from "../services/external-objects.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { ensureProjectAccessMember } from "../services/projects.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
@@ -137,11 +143,17 @@ export function projectRoutes(db: Db) {
|
||||
if (isUuidLike(rawId)) return rawId;
|
||||
const companyId = await resolveCompanyIdForProjectReference(req);
|
||||
if (!companyId) return rawId;
|
||||
const resolved = await svc.resolveByReference(companyId, rawId);
|
||||
if (resolved.ambiguous) {
|
||||
const urlKey = normalizeProjectUrlKey(rawId);
|
||||
if (!urlKey) return rawId;
|
||||
const visibleProjects = await filterProjectsForActor(
|
||||
req,
|
||||
await svc.list(companyId, { includeArchived: true }),
|
||||
);
|
||||
const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey);
|
||||
if (matches.length > 1) {
|
||||
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
||||
}
|
||||
return resolved.project?.id ?? rawId;
|
||||
return matches[0]?.id ?? rawId;
|
||||
}
|
||||
|
||||
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
||||
@@ -151,10 +163,87 @@ export function projectRoutes(db: Db) {
|
||||
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
||||
});
|
||||
if (decision.allowed) return true;
|
||||
res.status(403).json({ error: "Project is outside this actor's authorization boundary" });
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertCanManageProjectPrivacy(req: Request, res: Response, project: {
|
||||
companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null;
|
||||
}) {
|
||||
const actor = req.actor;
|
||||
if (actor.type === "board" && actor.userId) {
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
|
||||
if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true;
|
||||
const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
|
||||
if (instanceAdmin) return true;
|
||||
const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1);
|
||||
if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true;
|
||||
}
|
||||
res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) {
|
||||
const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId;
|
||||
if (ownerUserId) await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId,
|
||||
});
|
||||
if (req.actor.type === "board" && req.actor.userId) {
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.actor.type === "agent" && req.actor.agentId) {
|
||||
if (req.actor.onBehalfOfUserId) {
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.onBehalfOfUserId,
|
||||
});
|
||||
}
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: req.actor.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichProjectAccessMembers(rows: Array<typeof projectAccessMembers.$inferSelect>) {
|
||||
const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId);
|
||||
const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId);
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)),
|
||||
userIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds)),
|
||||
]);
|
||||
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
|
||||
const usersById = new Map(userRows.map((row) => [row.id, row]));
|
||||
return rows.map((row) => {
|
||||
const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null;
|
||||
const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null;
|
||||
return {
|
||||
...row,
|
||||
subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null,
|
||||
subjectAvatarUrl: user?.image ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
@@ -248,6 +337,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
||||
res.json(summary);
|
||||
});
|
||||
@@ -261,6 +351,7 @@ export function projectRoutes(db: Db) {
|
||||
};
|
||||
|
||||
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
|
||||
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
|
||||
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
||||
? await projectToolContext(db, req.actor, true) : null;
|
||||
await assertProjectEnvironmentSelection(
|
||||
@@ -305,6 +396,7 @@ export function projectRoutes(db: Db) {
|
||||
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true);
|
||||
const service = projectService(tx as unknown as Db);
|
||||
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
|
||||
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db);
|
||||
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
|
||||
const registeredUrls: typeof urlRepositories = [];
|
||||
for (const repo of urlRepositories) {
|
||||
@@ -347,7 +439,12 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const body = { ...req.body };
|
||||
if (body.visibility !== undefined) {
|
||||
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
|
||||
}
|
||||
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
@@ -399,10 +496,101 @@ export function projectRoutes(db: Db) {
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/access-members", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectAccessMembers)
|
||||
.where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id)));
|
||||
res.json(await enrichProjectAccessMembers(rows));
|
||||
});
|
||||
|
||||
router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
|
||||
const subjectType = req.body.subjectType as "user" | "agent";
|
||||
const subjectId = req.body.subjectId as string;
|
||||
const subjectExists = subjectType === "agent"
|
||||
? await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId)))
|
||||
.then((rows) => rows.length > 0)
|
||||
: await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, subjectId),
|
||||
eq(companyMemberships.companyId, project.companyId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
))
|
||||
.then((rows) => rows.length > 0);
|
||||
if (!subjectExists) {
|
||||
res.status(422).json({ error: "Project access member must belong to this company" });
|
||||
return;
|
||||
}
|
||||
await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId });
|
||||
const rows = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
eq(projectAccessMembers.subjectType, subjectType),
|
||||
eq(projectAccessMembers.subjectId, subjectId),
|
||||
));
|
||||
const [member] = await enrichProjectAccessMembers(rows);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_added",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType, subjectId },
|
||||
});
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
router.delete("/projects/:id/access-members/:memberId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
|
||||
const member = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.id, req.params.memberId as string),
|
||||
eq(projectAccessMembers.companyId, project.companyId),
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
if (!member) {
|
||||
res.status(404).json({ error: "Project access member not found" });
|
||||
return;
|
||||
}
|
||||
if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) {
|
||||
res.status(422).json({ error: "The project privacy owner cannot be removed" });
|
||||
return;
|
||||
}
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_removed",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType: member.subjectType, subjectId: member.subjectId },
|
||||
});
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/workspaces", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspaces = await svc.listWorkspaces(id);
|
||||
res.json(workspaces);
|
||||
});
|
||||
@@ -411,6 +599,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -450,6 +639,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -496,6 +686,7 @@ export function projectRoutes(db: Db) {
|
||||
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
|
||||
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
@@ -770,6 +961,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspace = await svc.removeWorkspace(id, workspaceId);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
@@ -798,6 +990,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const project = await svc.remove(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { accessService, heartbeatService, instanceSettingsService, issueService, logActivity, statusCardService } from "../services/index.js";
|
||||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
@@ -144,7 +144,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertStatusCardsEnabled();
|
||||
const query = listStatusCardsQuerySchema.parse(req.query);
|
||||
res.json(await service.list(companyId, query.archived));
|
||||
res.json(await service.list(companyId, query.archived, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => {
|
||||
@@ -171,7 +173,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
await assertStatusCardsEnabled();
|
||||
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
|
||||
if (!card) return;
|
||||
res.json(await service.hydrate(card));
|
||||
res.json(await service.hydrate(card, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
});
|
||||
|
||||
router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => {
|
||||
@@ -260,11 +264,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
if (!decision.allowed) {
|
||||
throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision));
|
||||
}
|
||||
const issueReadCondition = await issueReadSqlCondition(db, req.actor);
|
||||
res.json({
|
||||
cardId: card.id,
|
||||
queryVersion: card.queryVersion,
|
||||
queries: await service.dryRun(card),
|
||||
mentionedIssues: await service.listMentionedIssues(card),
|
||||
queries: await service.dryRun(card, { issueReadCondition }),
|
||||
mentionedIssues: await service.listMentionedIssues(card, { issueReadCondition }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export async function persistActivity(db: Db, input: LogActivityInput) {
|
||||
}).returning({ id: activityLog.id });
|
||||
|
||||
const payload = {
|
||||
issueId: input.issueId ?? (input.entityType === "issue" ? input.entityId : null),
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -27,6 +27,7 @@ export interface ActivityFilters {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
limit?: number;
|
||||
readCondition?: SQL<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_LIMIT = 100;
|
||||
@@ -366,7 +367,7 @@ export function activityService(db: Db) {
|
||||
...conditions,
|
||||
or(
|
||||
sql`${activityLog.entityType} != 'issue'`,
|
||||
visibleIssueCondition(),
|
||||
and(visibleIssueCondition(), filters.readCondition ?? sql<boolean>`true`),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -435,7 +436,7 @@ export function activityService(db: Db) {
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
or(
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
||||
eq(heartbeatRuns.issueId, issueId),
|
||||
sql`exists (
|
||||
select 1
|
||||
from ${activityLog}
|
||||
@@ -568,7 +569,7 @@ export function activityService(db: Db) {
|
||||
const run = await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
@@ -595,11 +596,7 @@ export function activityService(db: Db) {
|
||||
)
|
||||
.orderBy(issueIdAsText);
|
||||
|
||||
const context = run.contextSnapshot;
|
||||
const contextIssueId =
|
||||
context && typeof context === "object" && typeof (context as Record<string, unknown>).issueId === "string"
|
||||
? ((context as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
const contextIssueId = run.issueId;
|
||||
if (!contextIssueId) return fromActivity;
|
||||
if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
@@ -87,9 +87,10 @@ export function approvalService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, status?: string) => {
|
||||
list: (companyId: string, status?: string, readCondition?: SQL<boolean>) => {
|
||||
const conditions = [eq(approvals.companyId, companyId)];
|
||||
if (status) conditions.push(eq(approvals.status, status));
|
||||
if (readCondition) conditions.push(readCondition);
|
||||
return db.select().from(approvals).where(and(...conditions));
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import { executionIssueCondition } from "./issue-visibility.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js";
|
||||
import { canonicalizeStoredResolverPolicy } from "./issue-thread-interaction-resolution.js";
|
||||
import { decisionQueueService } from "./decision-queues.js";
|
||||
@@ -156,6 +157,8 @@ type BlockingIssueSummary = {
|
||||
|
||||
type AttentionListOptions = AttentionFeedQuery & {
|
||||
userId?: string | null;
|
||||
includeDismissed?: boolean;
|
||||
actor?: AuthorizationActor;
|
||||
/** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */
|
||||
allowUnscopedAll?: boolean;
|
||||
};
|
||||
@@ -814,7 +817,12 @@ async function dismissalByKey(db: Db, companyId: string, userId: string | null |
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function issueSummaryMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, IssueSummaryRow>();
|
||||
const rows = await db
|
||||
@@ -843,7 +851,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
|
||||
eq(issues.projectWorkspaceId, projectWorkspaces.id),
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
))
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), executionIssueCondition()));
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.id, ids),
|
||||
executionIssueCondition(),
|
||||
readCondition,
|
||||
));
|
||||
return new Map(rows.map((row) => [row.id, {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
@@ -870,7 +883,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueImageMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function issueImageMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, AttentionDetailImage[]>();
|
||||
const rows = await db
|
||||
@@ -881,11 +899,13 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array<string |
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, and(eq(issueAttachments.issueId, issues.id), eq(issues.companyId, companyId)))
|
||||
.where(and(
|
||||
eq(issueAttachments.companyId, companyId),
|
||||
eq(assets.companyId, companyId),
|
||||
inArray(issueAttachments.issueId, ids),
|
||||
sql`${assets.contentType} like 'image/%'`,
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id));
|
||||
|
||||
@@ -899,7 +919,12 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array<string |
|
||||
return map;
|
||||
}
|
||||
|
||||
async function planDocumentMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function planDocumentMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, PlanDocumentSummary>();
|
||||
const rows = await db
|
||||
@@ -910,16 +935,23 @@ async function planDocumentMap(db: Db, companyId: string, issueIds: Array<string
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, and(eq(issueDocuments.issueId, issues.id), eq(issues.companyId, companyId)))
|
||||
.where(and(
|
||||
eq(issueDocuments.companyId, companyId),
|
||||
eq(documents.companyId, companyId),
|
||||
eq(issueDocuments.key, "plan"),
|
||||
inArray(issueDocuments.issueId, ids),
|
||||
readCondition,
|
||||
));
|
||||
return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }]));
|
||||
}
|
||||
|
||||
async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array<string | null | undefined>) {
|
||||
async function blockingIssueMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
blockedIssueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, BlockingIssueSummary>();
|
||||
const rows = await db
|
||||
@@ -937,6 +969,7 @@ async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Arra
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, ids),
|
||||
isNull(issues.hiddenAt),
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id));
|
||||
const map = new Map<string, BlockingIssueSummary>();
|
||||
@@ -1076,6 +1109,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
if (options.all && !options.queue && !options.allowUnscopedAll) {
|
||||
throw badRequest("all requires a queue filter");
|
||||
}
|
||||
const readCondition = options.actor
|
||||
? await issueReadSqlCondition(db, options.actor)
|
||||
: sql<boolean>`true`;
|
||||
const [prefix, dismissals] = await Promise.all([
|
||||
companyPrefix(db, companyId),
|
||||
dismissalByKey(db, companyId, options.userId),
|
||||
@@ -1120,8 +1156,16 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
for (const row of approvalIssueRows) {
|
||||
if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId);
|
||||
}
|
||||
const readableApprovalIssues = await issueSummaryMap(
|
||||
db,
|
||||
companyId,
|
||||
approvalIssueRows.map((row) => row.issueId),
|
||||
readCondition,
|
||||
);
|
||||
|
||||
for (const approval of pendingApprovals) {
|
||||
const linkedIssueId = approvalIssueMap.get(approval.id) ?? null;
|
||||
if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue;
|
||||
const dedupKey = `approval:${approval.id}`;
|
||||
const title = approvalTitle(approval.type, approval.payload);
|
||||
add(createItem({
|
||||
@@ -1213,13 +1257,14 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
);
|
||||
const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows);
|
||||
const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([
|
||||
issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)),
|
||||
issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)),
|
||||
planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)),
|
||||
issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
|
||||
issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
|
||||
planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
|
||||
]);
|
||||
|
||||
for (const interaction of visibleInteractionRows) {
|
||||
const issue = interactionIssueMap.get(interaction.issueId) ?? null;
|
||||
if (!issue) continue;
|
||||
const payload = readRecord(interaction.payload);
|
||||
const detail = interactionDetail({
|
||||
kind: interaction.kind,
|
||||
@@ -1291,7 +1336,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
const bundleIds = [...new Set(openDecisions.map((decision) => decision.bundleId).filter((value): value is string => Boolean(value)))];
|
||||
const bundleTitleMap = new Map<string, string>();
|
||||
const [decisionIssueMap, bundleRows] = await Promise.all([
|
||||
issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId)),
|
||||
issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId), readCondition),
|
||||
bundleIds.length > 0
|
||||
? db.select({ id: decisionBundles.id, title: decisionBundles.title })
|
||||
.from(decisionBundles).where(and(eq(decisionBundles.companyId, companyId), inArray(decisionBundles.id, bundleIds)))
|
||||
@@ -1300,6 +1345,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
for (const row of bundleRows) bundleTitleMap.set(row.id, row.title);
|
||||
for (const decision of openDecisions) {
|
||||
const issue = decisionIssueMap.get(decision.originIssueId) ?? null;
|
||||
if (decision.originIssueId && !issue) continue;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "decision",
|
||||
@@ -1400,12 +1446,14 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
db,
|
||||
companyId,
|
||||
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
|
||||
readCondition,
|
||||
),
|
||||
issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId)),
|
||||
issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition),
|
||||
]);
|
||||
|
||||
for (const recovery of recoveryRows) {
|
||||
const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null;
|
||||
if (!sourceIssue) continue;
|
||||
const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null;
|
||||
const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`;
|
||||
add(createItem({
|
||||
@@ -1450,7 +1498,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
}));
|
||||
}
|
||||
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true, readCondition });
|
||||
type BlockedAttentionIssue = IssueSubjectRow & {
|
||||
blockerAttention?: {
|
||||
state?: string;
|
||||
@@ -1467,14 +1515,15 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
.map((issue) => issue.blockerAttention?.terminalBlockerIssueId)
|
||||
.filter((issueId): issueId is string => Boolean(issueId));
|
||||
const [blockedIssueSummaries, terminalBlockerSummaries, blockerImageMap, blockingIssues] = await Promise.all([
|
||||
issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)),
|
||||
issueSummaryMap(db, companyId, terminalBlockerIssueIds),
|
||||
issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition),
|
||||
issueSummaryMap(db, companyId, terminalBlockerIssueIds, readCondition),
|
||||
issueImageMap(
|
||||
db,
|
||||
companyId,
|
||||
[...blockedIssues.map((issue) => issue.id), ...terminalBlockerIssueIds],
|
||||
readCondition,
|
||||
),
|
||||
blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)),
|
||||
blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition),
|
||||
]);
|
||||
const terminalCandidates = new Map<string, {
|
||||
issue: BlockedAttentionIssue;
|
||||
@@ -1585,7 +1634,12 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), executionIssueCondition()))
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.status, "in_review"),
|
||||
executionIssueCondition(),
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const reviewIssueIds = reviewRows.map((row) => row.id);
|
||||
const pendingReviewApprovalRows = reviewIssueIds.length === 0
|
||||
@@ -1603,8 +1657,8 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId]));
|
||||
const [reviewAttentionByIssueId, reviewIssueMap, reviewImageMap] = await Promise.all([
|
||||
issueService(db).listReviewAttention(companyId, reviewRows),
|
||||
issueSummaryMap(db, companyId, reviewIssueIds),
|
||||
issueImageMap(db, companyId, reviewIssueIds),
|
||||
issueSummaryMap(db, companyId, reviewIssueIds, readCondition),
|
||||
issueImageMap(db, companyId, reviewIssueIds, readCondition),
|
||||
]);
|
||||
|
||||
for (const review of reviewRows) {
|
||||
@@ -1673,8 +1727,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
db,
|
||||
companyId,
|
||||
failedIssueIds,
|
||||
readCondition,
|
||||
),
|
||||
issueImageMap(db, companyId, failedIssueIds),
|
||||
issueImageMap(db, companyId, failedIssueIds, readCondition),
|
||||
oldestFailedRunCreatedAt && failedAgentIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
@@ -1709,6 +1764,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
if (hasNewerRun) continue;
|
||||
|
||||
const issue = issueId ? failedIssueMap.get(issueId) ?? null : null;
|
||||
if (issueId && !issue) continue;
|
||||
const dedupKey = `run:${run.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { withHumanDirectedWork } from "./human-directed-work.js";
|
||||
import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
issueApprovals,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatIdentityLinks,
|
||||
heartbeatRuns,
|
||||
executionWorkspaces,
|
||||
instanceUserRoles,
|
||||
issueComments,
|
||||
issueAccessGrants,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -140,6 +148,8 @@ export type AuthorizationDecision = {
|
||||
| "deny_no_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_issue_private"
|
||||
| "deny_project_private"
|
||||
| "deny_scope"
|
||||
| "deny_unsupported_action";
|
||||
grant?: {
|
||||
@@ -262,7 +272,7 @@ type ProjectAuthorizationRow = {
|
||||
type IssueAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectId?: string | null;
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -502,6 +512,160 @@ type ResponsibleUserActorWithMemo = AuthorizationActor & {
|
||||
__responsibleUserSnapshotMemo?: Map<string, Promise<ResponsibleUserSnapshot>>;
|
||||
};
|
||||
|
||||
const responsibleUserSnapshotCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; promise: Promise<ResponsibleUserSnapshot> }
|
||||
>();
|
||||
|
||||
export type IssuePrivacyRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
privacyRootIssueId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectPrivacyRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
};
|
||||
|
||||
export function issuePrivacyMode(): "off" | "shadow" | "enforce" {
|
||||
const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase();
|
||||
if (mode === "off" || mode === "shadow") return mode;
|
||||
return "enforce";
|
||||
}
|
||||
|
||||
function issuePrivacyPrincipal(actor: AuthorizationActor) {
|
||||
if (actor.type === "board" && actor.userId) return { type: "user" as const, id: actor.userId };
|
||||
if (actor.type === "agent" && actor.agentId) return { type: "agent" as const, id: actor.agentId };
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectPrivacyPrincipalCondition(actor: AuthorizationActor): SQL<boolean> {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
return sql<boolean>`(${projects.visibility} = 'open' or ${principal ? sql`exists (
|
||||
select 1 from ${projectAccessMembers} m where m.company_id = ${projects.companyId}
|
||||
and m.project_id = ${projects.id} and m.subject_type = ${principal.type}
|
||||
and m.subject_id = ${principal.id})` : sql`false`})`;
|
||||
}
|
||||
|
||||
function projectPrivacyCondition(actor: AuthorizationActor): SQL<boolean> {
|
||||
const own = projectPrivacyPrincipalCondition(actor);
|
||||
if (actor.type !== "agent" || !actor.onBehalfOfUserId) return own;
|
||||
return sql<boolean>`(${own} and ${projectPrivacyPrincipalCondition({ type: "board", userId: actor.onBehalfOfUserId })}
|
||||
and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId}
|
||||
and m.principal_type = 'user' and m.principal_id = ${actor.onBehalfOfUserId} and m.status = 'active'))`;
|
||||
}
|
||||
|
||||
export async function canActorReadProjectPrivacy(db: Db | DbTransaction, actor: AuthorizationActor, project: ProjectPrivacyRow) {
|
||||
return db.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, project.id), eq(projects.companyId, project.companyId), projectPrivacyCondition(actor)))
|
||||
.limit(1).then(rows => rows.length > 0);
|
||||
}
|
||||
|
||||
/** One predicate for direct reads and SQL pushdown. Grants flow only from a task
|
||||
* to its privacy descendants, never from a child to its ancestors or siblings.
|
||||
* UNION bounds traversal even if historical data contains a cycle. */
|
||||
function issuePrivacyPrincipalCondition(actor: AuthorizationActor): SQL<boolean> {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
const implicit = principal?.type === "agent"
|
||||
? sql`n.assignee_agent_id = ${principal.id}`
|
||||
: principal?.type === "user"
|
||||
? sql`(n.responsible_user_id = ${principal.id} or n.created_by_user_id = ${principal.id} or n.assignee_user_id = ${principal.id})`
|
||||
: sql`false`;
|
||||
const grant = principal ? sql`exists (
|
||||
select 1 from ${issueAccessGrants} g where g.issue_id = n.id
|
||||
and g.subject_type = ${principal.type} and g.subject_id = ${principal.id}
|
||||
and g.revoked_at is null
|
||||
)` : sql`false`;
|
||||
const projectMember = principal ? sql`exists (
|
||||
select 1 from ${projectAccessMembers} m
|
||||
join ${projects} p on p.id = m.project_id and p.company_id = m.company_id
|
||||
where p.id = n.project_id and p.company_id = n.company_id and p.visibility = 'private'
|
||||
and m.subject_type = ${principal.type} and m.subject_id = ${principal.id}
|
||||
)` : sql`false`;
|
||||
return sql<boolean>`exists (
|
||||
with recursive privacy_ancestors as (
|
||||
select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id,
|
||||
p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id
|
||||
from issues p where p.id = ${issues.id} and p.company_id = ${issues.companyId}
|
||||
union
|
||||
select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id,
|
||||
p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id
|
||||
from issues p join privacy_ancestors c on p.id = c.privacy_parent_issue_id and p.company_id = c.company_id
|
||||
where p.visibility = 'private' or exists (select 1 from projects pp where pp.id = p.project_id and pp.company_id = p.company_id and pp.visibility = 'private')
|
||||
)
|
||||
select 1 from privacy_ancestors n
|
||||
where ${implicit} or ${grant} or ${projectMember}
|
||||
or not exists (
|
||||
select 1 from privacy_ancestors restricted
|
||||
left join projects private_project on private_project.id = restricted.project_id
|
||||
and private_project.company_id = restricted.company_id
|
||||
where restricted.visibility = 'private' or private_project.visibility = 'private'
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
function issuePrivacyCondition(actor: AuthorizationActor): SQL<boolean> {
|
||||
const principalCondition = issuePrivacyPrincipalCondition(actor);
|
||||
if (actor.type !== "agent" || !actor.onBehalfOfUserId) return principalCondition;
|
||||
const userId = actor.onBehalfOfUserId;
|
||||
// A shared agent's grants cannot amplify the user whose run is making the call.
|
||||
return sql<boolean>`(${principalCondition} and ${issuePrivacyPrincipalCondition({ type: "board", userId })}
|
||||
and exists (select 1 from ${companyMemberships} m where m.company_id = ${issues.companyId}
|
||||
and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`;
|
||||
}
|
||||
|
||||
export async function canActorReadIssuePrivacy(db: Db | DbTransaction, actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
||||
return db.select({ id: issues.id }).from(issues)
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), issuePrivacyCondition(actor)))
|
||||
.limit(1).then(rows => rows.length > 0);
|
||||
}
|
||||
|
||||
/** Filter every externally reachable task query with the same privacy predicate. */
|
||||
export async function issueReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
||||
if (actor.type === "board" && !actor.ignoreInstanceAdmin && actor.source !== "cloud_tenant"
|
||||
&& await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, actor.userId ?? ""), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.limit(1).then(rows => rows.length > 0)) return sql<boolean>`true`;
|
||||
return issuePrivacyCondition(actor);
|
||||
}
|
||||
|
||||
/** SQL equivalent used for project list/search pushdown. */
|
||||
export async function projectReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
||||
if (
|
||||
actor.type === "board"
|
||||
&& !actor.ignoreInstanceAdmin
|
||||
&& actor.source !== "cloud_tenant"
|
||||
&& Boolean(await db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(
|
||||
eq(instanceUserRoles.userId, actor.userId ?? ""),
|
||||
eq(instanceUserRoles.role, "instance_admin"),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null))
|
||||
) return sql<boolean>`true`;
|
||||
return projectPrivacyCondition(actor);
|
||||
}
|
||||
|
||||
function responsibleUserSnapshotTtlMs() {
|
||||
const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim();
|
||||
if (!raw) return 5_000;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000;
|
||||
}
|
||||
export function responsibleUserAuthzShadowMode() {
|
||||
const mode = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE?.trim().toLowerCase();
|
||||
const shadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW?.trim().toLowerCase();
|
||||
@@ -2462,7 +2626,84 @@ export function authorizationService(db: Db | DbTransaction) {
|
||||
scope?: Record<string, unknown> | null;
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const agentDecision = await decideBase(input);
|
||||
return applyResponsibleUserIntersection(input, agentDecision);
|
||||
const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision);
|
||||
if (
|
||||
input.action === "project:read"
|
||||
&& input.resource.type === "project"
|
||||
&& input.resource.projectId
|
||||
&& intersectedDecision.allowed
|
||||
&& intersectedDecision.reason !== "allow_instance_admin"
|
||||
&& intersectedDecision.reason !== "allow_local_board"
|
||||
&& issuePrivacyMode() !== "off"
|
||||
) {
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) {
|
||||
const denied = deny({
|
||||
action: input.action,
|
||||
reason: "deny_project_private",
|
||||
explanation: "Project is private and the actor is not an access member.",
|
||||
});
|
||||
logger.warn({
|
||||
authzMode: issuePrivacyMode(),
|
||||
action: input.action,
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
actorType: input.actor.type,
|
||||
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
||||
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
reason: denied.reason,
|
||||
}, "project privacy would deny read");
|
||||
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.action !== "issue:read"
|
||||
|| input.resource.type !== "issue"
|
||||
|| !input.resource.issueId
|
||||
|| !intersectedDecision.allowed
|
||||
|| intersectedDecision.reason === "allow_instance_admin"
|
||||
|| intersectedDecision.reason === "allow_local_board"
|
||||
|| issuePrivacyMode() === "off"
|
||||
) return intersectedDecision;
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
visibility: issues.visibility,
|
||||
privacyRootIssueId: issues.privacyRootIssueId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision;
|
||||
|
||||
const denied = deny({
|
||||
action: input.action,
|
||||
reason: "deny_issue_private",
|
||||
explanation: "Issue is private and the actor is not an implicit principal or active grantee.",
|
||||
});
|
||||
logger.warn({
|
||||
authzMode: issuePrivacyMode(),
|
||||
action: input.action,
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
privacyRootIssueId: issue.privacyRootIssueId,
|
||||
actorType: input.actor.type,
|
||||
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
||||
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
reason: denied.reason,
|
||||
}, "issue privacy would deny read");
|
||||
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
||||
}
|
||||
|
||||
// A candidate filter only: project policies and responsible-user grants are
|
||||
@@ -2490,3 +2731,83 @@ export function authorizationService(db: Db | DbTransaction) {
|
||||
decidePrincipalGrant,
|
||||
};
|
||||
}
|
||||
|
||||
/** A workspace can contain several tasks. Reading files/operations requires
|
||||
* access to every task bound to it, not just the task that originally made it. */
|
||||
export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
const issueCondition = await issueReadSqlCondition(db, actor);
|
||||
const projectCondition = await projectReadSqlCondition(db, actor);
|
||||
return sql<boolean>`(
|
||||
(${executionWorkspaces.projectId} is null or exists (
|
||||
select 1 from ${projects} where ${projects.id} = ${executionWorkspaces.projectId}
|
||||
and ${projects.companyId} = ${executionWorkspaces.companyId} and ${projectCondition}))
|
||||
and not exists (select 1 from jsonb_object_keys(coalesce(${executionWorkspaces.metadata}->'_issuePrivacySources', '{}'::jsonb)) source(id)
|
||||
where not exists (select 1 from ${issues} where ${issues.id}::text = source.id
|
||||
and ${issues.companyId} = ${executionWorkspaces.companyId} and ${issueCondition}))
|
||||
and not exists (select 1 from ${issues}
|
||||
where ${issues.companyId} = ${executionWorkspaces.companyId}
|
||||
and (${issues.executionWorkspaceId} = ${executionWorkspaces.id} or ${issues.id} = ${executionWorkspaces.sourceIssueId})
|
||||
and not ${issueCondition})
|
||||
)`;
|
||||
}
|
||||
|
||||
/** An approval may contain task-derived payload; every linked task must be readable. */
|
||||
export async function approvalReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
const readable = await issueReadSqlCondition(db, actor);
|
||||
return sql<boolean>`not exists (
|
||||
select 1 from (values (${approvals.payload}->>'issueId'), (${approvals.payload}->>'sourceIssueId')) claimed(id)
|
||||
where claimed.id is not null and not exists (
|
||||
select 1 from ${issues} where ${issues.id}::text = claimed.id and ${issues.companyId} = ${approvals.companyId})
|
||||
) and not exists (
|
||||
select 1 from ${issues} where ${issues.companyId} = ${approvals.companyId}
|
||||
and (${issues.id}::text = ${approvals.payload}->>'issueId'
|
||||
or ${issues.id}::text = ${approvals.payload}->>'sourceIssueId'
|
||||
or exists (select 1 from ${issueApprovals} ia where ia.approval_id = ${approvals.id}
|
||||
and ia.company_id = ${approvals.companyId} and ia.issue_id = ${issues.id}))
|
||||
and not (${readable})
|
||||
)`;
|
||||
}
|
||||
|
||||
export async function canActorReadApproval(db: Db | DbTransaction, actor: AuthorizationActor, approvalId: string) {
|
||||
return db.select({ id: approvals.id }).from(approvals)
|
||||
.where(and(eq(approvals.id, approvalId), await approvalReadSqlCondition(db, actor)))
|
||||
.limit(1).then(rows => rows.length > 0);
|
||||
}
|
||||
|
||||
export async function canActorReadExecutionWorkspace(db: Db, actor: AuthorizationActor, id: string) {
|
||||
return db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, id), await executionWorkspaceReadSqlCondition(db, actor)))
|
||||
.limit(1).then(rows => rows.length > 0);
|
||||
}
|
||||
|
||||
/** Recheck the external audience immediately before publishing task-derived content. */
|
||||
export async function canPublishIssueToChatAudience(
|
||||
tx: Db | DbTransaction,
|
||||
publication: { issueId: string; companyId: string; conversationId: string; endpointId: string },
|
||||
): Promise<boolean> {
|
||||
const [privacyIssue] = await tx.select().from(issues)
|
||||
.where(and(eq(issues.id, publication.issueId), eq(issues.companyId, publication.companyId)));
|
||||
if (!privacyIssue) return false;
|
||||
if (!(await canActorReadIssuePrivacy(tx, { type: "none" }, privacyIssue))) {
|
||||
const [conversation] = await tx.select().from(chatConversations).where(and(
|
||||
eq(chatConversations.id, publication.conversationId), eq(chatConversations.companyId, publication.companyId),
|
||||
eq(chatConversations.endpointId, publication.endpointId)));
|
||||
// Shared channels have no bounded, authenticated recipient set. Private
|
||||
// output may leave only through a DM whose known recipients still qualify.
|
||||
if (!conversation?.isDirectMessage) return false;
|
||||
const recipients = await tx.selectDistinct({ principalId: chatDeliveries.principalId }).from(chatDeliveries)
|
||||
.where(and(eq(chatDeliveries.conversationId, conversation.id), eq(chatDeliveries.companyId, publication.companyId),
|
||||
inArray(chatDeliveries.eventKind, ["message", "mention"])));
|
||||
if (!recipients.length || recipients.some(recipient => !recipient.principalId)) return false;
|
||||
for (const recipient of recipients) {
|
||||
const [link] = await tx.select().from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, publication.companyId), eq(chatIdentityLinks.endpointId, publication.endpointId),
|
||||
eq(chatIdentityLinks.principalId, recipient.principalId!), eq(chatIdentityLinks.status, "linked"), isNull(chatIdentityLinks.revokedAt)));
|
||||
if (!link?.paperclipUserId) return false;
|
||||
const allowed = await authorizationService(tx).decide({ actor: { type: "board", userId: link.paperclipUserId, source: "session", ignoreInstanceAdmin: true },
|
||||
action: "issue:read", resource: { type: "issue", companyId: publication.companyId, issueId: publication.issueId } });
|
||||
if (!allowed.allowed) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authorizationService, canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js";
|
||||
import { withSlackBoardLease } from "./slack-board-lease.js";
|
||||
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
|
||||
import { authorizeSlackBoardPublication } from "./slack-board-authority.js";
|
||||
@@ -11767,6 +11768,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
if (!admission?.allowed) throw deny();
|
||||
authorization.userId = admission.responsibleUserId ?? null;
|
||||
}
|
||||
if (!(await canActorReadIssuePrivacy(tx, authorization.userId
|
||||
? { type: "board", userId: authorization.userId }
|
||||
: { type: "none" }, issue))) throw deny();
|
||||
const expectedUserId =
|
||||
payload.requestedByActorType === "user"
|
||||
? payload.requestedByActorId
|
||||
@@ -13637,6 +13641,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
tx: DbOrTransaction,
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
): Promise<boolean> {
|
||||
if (!(await canPublishIssueToChatAudience(tx, publication))) return false;
|
||||
const notice = parseInboundWakePublicationKey(publication.idempotencyKey);
|
||||
let runId = runIdFromMilestonePublication(publication);
|
||||
if (!runId && publication.commentId && !notice) {
|
||||
|
||||
@@ -151,7 +151,11 @@ function extractMatches(sources: ExtractSource[], query: CompanySearchExtractQue
|
||||
|
||||
export function companySearchExtractService(db: Db) {
|
||||
return {
|
||||
extract: async (companyId: string, query: CompanySearchExtractQuery): Promise<CompanySearchExtractResponse> => {
|
||||
extract: async (
|
||||
companyId: string,
|
||||
query: CompanySearchExtractQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchExtractResponse> => {
|
||||
const containsPattern = `%${escapeLikePattern(query.contains)}%`;
|
||||
const urlPattern = urlContainsPattern(query.contains);
|
||||
const scopeConditions: SQL[] = [];
|
||||
@@ -198,6 +202,7 @@ export function companySearchExtractService(db: Db) {
|
||||
visibleIssueCondition(),
|
||||
or(...scopeConditions)!,
|
||||
];
|
||||
if (options?.issueReadCondition) conditions.push(options.issueReadCondition);
|
||||
if (query.status.length > 0) conditions.push(inArray(issues.status, query.status));
|
||||
const updatedWithin = updatedWithinStart(query.updatedWithin);
|
||||
if (updatedWithin) conditions.push(gte(issues.updatedAt, updatedWithin));
|
||||
|
||||
@@ -539,7 +539,11 @@ export function companySearchService(db: Db) {
|
||||
const extractService = companySearchExtractService(db);
|
||||
return {
|
||||
extract: extractService.extract,
|
||||
search: async (companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse> => {
|
||||
search: async (
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchResponse> => {
|
||||
const taskSearch = parseTaskSearch(query.q);
|
||||
const normalizedQuery = taskSearch.normalizedQuery;
|
||||
const hasSearchText = normalizedQuery.length > 0;
|
||||
@@ -718,7 +722,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
const resultRows = await db.execute(sql`
|
||||
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters))}
|
||||
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters), options?.issueReadCondition)}
|
||||
${sql.join(branches, sql` UNION ALL `)}
|
||||
`) as unknown as Array<SearchAggregateRow & Omit<IssueSearchRow, "commentSnippet" | "commentId" | "documentSnippet" | "documentTitle" | "documentKey">>;
|
||||
|
||||
@@ -868,6 +872,7 @@ export function companySearchService(db: Db) {
|
||||
sql`${projects.name}`,
|
||||
sql`${projects.description}`,
|
||||
], containsPattern, tokenPatternArray);
|
||||
const projectReadCondition = options?.projectReadCondition ?? sql<boolean>`true`;
|
||||
|
||||
async function fetchAgentRows() {
|
||||
if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
|
||||
@@ -897,7 +902,7 @@ export function companySearchService(db: Db) {
|
||||
updatedAt: projects.updatedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition))
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition))
|
||||
.orderBy(desc(projects.updatedAt), desc(projects.id))
|
||||
.limit(fetchLimit);
|
||||
}
|
||||
@@ -984,7 +989,7 @@ export function companySearchService(db: Db) {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition));
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition));
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ type SourceDecision = {
|
||||
};
|
||||
|
||||
type ListInput = {
|
||||
readCondition?: SQL<boolean>;
|
||||
projectId?: string;
|
||||
kind?: DecisionTrainingSourceKind;
|
||||
author?: string;
|
||||
@@ -304,6 +305,7 @@ export function decisionTrainingService(db: Db) {
|
||||
},
|
||||
list: async (companyId: string, input: ListInput = {}) => {
|
||||
const filters: SQL[] = [eq(decisionTrainingExamples.companyId, companyId)];
|
||||
if (input.readCondition) filters.push(input.readCondition);
|
||||
if (input.projectId) filters.push(eq(issues.projectId, input.projectId));
|
||||
if (input.kind) filters.push(eq(decisionTrainingExamples.sourceKind, input.kind));
|
||||
if (input.author) filters.push(eq(decisionTrainingExamples.createdByUserId, input.author));
|
||||
|
||||
@@ -452,7 +452,7 @@ export async function buildExecutionContinuation(input: {
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
result: row.result,
|
||||
})), ...await childReviewOutcomes(db, companyId, issueId)],
|
||||
})), ...await childReviewOutcomes(db, companyId, issueId, { type: "agent", agentId: input.agentId, companyId, onBehalfOfUserId: issue.responsibleUserId })],
|
||||
// Low-trust evidence only: renderPaperclipWakePrompt removes completedWork
|
||||
// from requestContext and encodes it in the fenced, non-authoritative
|
||||
// continuation-evidence section. It cannot supply objective or authority.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -1832,9 +1833,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
readCondition?: SQL<boolean>;
|
||||
},
|
||||
) {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters?.readCondition) conditions.push(filters.readCondition);
|
||||
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters?.projectWorkspaceId) {
|
||||
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
|
||||
@@ -1869,8 +1872,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
listOverview: async (
|
||||
companyId: string,
|
||||
filters: WorkspaceOverviewQuery,
|
||||
readCondition?: SQL<boolean>,
|
||||
): Promise<WorkspaceOverviewResponse> => {
|
||||
const conditions = buildOverviewConditions(companyId, filters);
|
||||
if (readCondition) conditions.push(readCondition);
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
const [totalRow, rows] = await Promise.all([
|
||||
@@ -2081,6 +2086,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
readCondition?: SQL<boolean>;
|
||||
}) => {
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
const rows = await db
|
||||
@@ -2107,6 +2113,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
readCondition?: SQL<boolean>;
|
||||
}) => {
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
const rows = await db
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issues, heartbeatRuns } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { canActorReadExecutionWorkspace, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
|
||||
|
||||
type RunIssueBinding = {
|
||||
companyId: string;
|
||||
scopeKind?: "company" | "issue" | null;
|
||||
issueId?: string | null;
|
||||
};
|
||||
|
||||
export async function canActorReadHeartbeatRun(
|
||||
db: Db,
|
||||
access: {
|
||||
decide(input: {
|
||||
actor: AuthorizationActor;
|
||||
action: "issue:read";
|
||||
resource: {
|
||||
type: "issue";
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
projectId: null;
|
||||
parentIssueId: null;
|
||||
assigneeAgentId: null;
|
||||
assigneeUserId: null;
|
||||
};
|
||||
}): Promise<AuthorizationDecision>;
|
||||
},
|
||||
actor: AuthorizationActor,
|
||||
run: RunIssueBinding,
|
||||
): Promise<boolean> {
|
||||
if (run.scopeKind === "company") return true;
|
||||
if (!run.issueId) return false;
|
||||
|
||||
const issueExists = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, run.issueId), eq(issues.companyId, run.companyId)))
|
||||
.then((rows) => rows.length > 0);
|
||||
if (!issueExists) return false;
|
||||
|
||||
const decision = await access.decide({
|
||||
actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: run.companyId,
|
||||
issueId: run.issueId,
|
||||
projectId: null,
|
||||
parentIssueId: null,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
});
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstFinite(...values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
const parsed = finiteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickTokenUsage(value: unknown) {
|
||||
const usage = asRecord(value);
|
||||
if (!usage) return null;
|
||||
const picked = {
|
||||
inputTokens: firstFinite(usage.inputTokens, usage.input_tokens),
|
||||
cachedInputTokens: firstFinite(usage.cachedInputTokens, usage.cached_input_tokens),
|
||||
outputTokens: firstFinite(usage.outputTokens, usage.output_tokens),
|
||||
totalTokens: firstFinite(usage.totalTokens, usage.total_tokens),
|
||||
};
|
||||
return Object.values(picked).some((item) => item !== null) ? picked : null;
|
||||
}
|
||||
|
||||
function runDurationMs(row: Record<string, unknown>) {
|
||||
const startedAt = row.startedAt instanceof Date ? row.startedAt : new Date(String(row.startedAt ?? ""));
|
||||
const finishedAt = row.finishedAt instanceof Date ? row.finishedAt : new Date(String(row.finishedAt ?? ""));
|
||||
if (Number.isNaN(startedAt.getTime())) return null;
|
||||
const endMs = Number.isNaN(finishedAt.getTime()) ? Date.now() : finishedAt.getTime();
|
||||
return Math.max(0, endMs - startedAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked list-row disclosure for an issue-bound run the actor cannot read.
|
||||
* Keep only identity, timing, status, and budget-oversight fields.
|
||||
*/
|
||||
export function redactHeartbeatRunListRow<T extends Record<string, unknown> & { id: string }>(row: T) {
|
||||
const usage = asRecord(row.usageJson);
|
||||
const result = asRecord(row.resultJson);
|
||||
const costUsd = firstFinite(
|
||||
row.costUsd,
|
||||
result?.costUsd,
|
||||
result?.cost_usd,
|
||||
result?.total_cost_usd,
|
||||
usage?.costUsd,
|
||||
usage?.cost_usd,
|
||||
usage?.total_cost_usd,
|
||||
);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
agentId: row.agentId,
|
||||
agentName: row.agentName,
|
||||
adapterType: row.adapterType,
|
||||
invocationSource: row.invocationSource,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt ?? null,
|
||||
finishedAt: row.finishedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
durationMs: runDurationMs(row),
|
||||
costUsd,
|
||||
usageJson: pickTokenUsage(row.usageJson ?? {
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
totalTokens: row.totalTokens,
|
||||
}),
|
||||
redacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check all durable sources, including tombstones left after a task/run is deleted. */
|
||||
export async function canActorReadWorkspaceOperation(
|
||||
db: Db,
|
||||
access: Parameters<typeof canActorReadHeartbeatRun>[1],
|
||||
actor: AuthorizationActor,
|
||||
operation: { companyId: string; issueId?: string | null; heartbeatRunId?: string | null; executionWorkspaceId?: string | null; metadata?: Record<string, unknown> | null },
|
||||
): Promise<boolean> {
|
||||
const sourceIds = (key: string, current?: string | null) => {
|
||||
const history = asRecord(operation.metadata?.[key]);
|
||||
return new Set([...Object.keys(history ?? {}), ...(current ? [current] : [])]);
|
||||
};
|
||||
const issueIds = sourceIds("_issuePrivacySources", operation.issueId);
|
||||
const runIds = sourceIds("_runPrivacySources", operation.heartbeatRunId);
|
||||
for (const issueId of issueIds) {
|
||||
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(issueId)) return false;
|
||||
if (!(await canActorReadHeartbeatRun(db, access, actor, { companyId: operation.companyId, scopeKind: "issue", issueId }))) return false;
|
||||
}
|
||||
for (const runId of runIds) {
|
||||
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(runId)) return false;
|
||||
const [run] = await db.select().from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, operation.companyId))).limit(1);
|
||||
if (!run || !(await canActorReadHeartbeatRun(db, access, actor, run))) return false;
|
||||
}
|
||||
if (operation.executionWorkspaceId) {
|
||||
if (!(await canActorReadExecutionWorkspace(db, actor, operation.executionWorkspaceId))) return false;
|
||||
}
|
||||
return issueIds.size > 0 || runIds.size > 0 || Boolean(operation.executionWorkspaceId);
|
||||
}
|
||||
@@ -3338,9 +3338,20 @@ const heartbeatRunListColumns = {
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
scopeKind: heartbeatRuns.scopeKind,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
status: heartbeatRuns.status,
|
||||
inputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
|
||||
cachedInputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"),
|
||||
outputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"),
|
||||
totalTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"),
|
||||
costUsd: sql<number | null>`coalesce(
|
||||
(${heartbeatRuns.resultJson} ->> 'costUsd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric
|
||||
)`.as("costUsd"),
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
error: heartbeatRuns.error,
|
||||
@@ -3395,34 +3406,17 @@ const heartbeatRunSummaryListColumns = {
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListContextColumns = {
|
||||
contextIssueId: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
|
||||
contextTaskId: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
contextTaskKey: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
|
||||
contextCommentId: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
|
||||
contextWakeCommentId: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as(
|
||||
"contextWakeCommentId",
|
||||
),
|
||||
contextWakeReason: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"),
|
||||
contextWakeSource: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"),
|
||||
contextWakeTriggerDetail: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as(
|
||||
"contextWakeTriggerDetail",
|
||||
),
|
||||
contextIssueId: sql<string | null>`coalesce(
|
||||
${heartbeatRuns.issueId}::text,
|
||||
${heartbeatRuns.contextSnapshot} ->> 'issueId'
|
||||
)`.as("contextIssueId"),
|
||||
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
contextTaskKey: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
|
||||
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
|
||||
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
|
||||
contextWakeReason: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"),
|
||||
contextWakeSource: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"),
|
||||
contextWakeTriggerDetail: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"),
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListResultColumns = {
|
||||
@@ -3612,6 +3606,8 @@ const heartbeatRunSqlAsciiSafeColumns = {
|
||||
const heartbeatRunLogAccessColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
scopeKind: heartbeatRuns.scopeKind,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
logStore: heartbeatRuns.logStore,
|
||||
logRef: heartbeatRuns.logRef,
|
||||
} as const;
|
||||
@@ -3645,9 +3641,7 @@ const heartbeatRunIssueSummaryColumns = {
|
||||
lastOutputSeq: heartbeatRuns.lastOutputSeq,
|
||||
lastOutputStream: heartbeatRuns.lastOutputStream,
|
||||
lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
||||
issueId: sql<
|
||||
string | null
|
||||
>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
issueId: heartbeatRuns.issueId,
|
||||
} as const;
|
||||
|
||||
function appendExcerpt(prev: string, chunk: string) {
|
||||
@@ -14315,6 +14309,8 @@ export function heartbeatService(
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
scopeKind: "issue",
|
||||
issueId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
@@ -15538,7 +15534,10 @@ export function heartbeatService(
|
||||
: null;
|
||||
const transientRetryNotBefore = transientRecovery?.retryNotBefore ?? null;
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
// A retry inherits the durable authorization scope of its source run. Do
|
||||
// not promote an untrusted or legacy contextSnapshot.issueId into a new
|
||||
// issue binding: that could either violate the FK or misclassify history.
|
||||
const issueId = run.scopeKind === "issue" ? run.issueId : null;
|
||||
|
||||
if (!baseSchedule) {
|
||||
const exhaustion = {
|
||||
@@ -16112,6 +16111,8 @@ export function heartbeatService(
|
||||
id: scheduledRunId,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
scopeKind: run.scopeKind,
|
||||
issueId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
@@ -28927,6 +28928,8 @@ export function heartbeatService(
|
||||
...(explicitContinuation ? { id: explicitContinuationRunId } : {}),
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company",
|
||||
issueId: readNonEmptyString(enrichedContextSnapshot.issueId),
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
@@ -29199,6 +29202,8 @@ export function heartbeatService(
|
||||
.values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company",
|
||||
issueId: readNonEmptyString(enrichedContextSnapshot.issueId),
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
} from "./issue-review-policy.js";
|
||||
import {
|
||||
issueService,
|
||||
ensureAssignmentIssueAccessGrant,
|
||||
readAcceptedPlanConfirmationTarget,
|
||||
runWorkspaceIsFinalized,
|
||||
} from "./issues.js";
|
||||
@@ -3693,6 +3694,13 @@ export function issueThreadInteractionService(
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (row.addresseeAgentId || row.addresseeUserId) {
|
||||
const [privacyIssue] = await tx.select().from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
||||
await ensureAssignmentIssueAccessGrant(tx, { ...privacyIssue!,
|
||||
assigneeAgentId: row.addresseeAgentId, assigneeUserId: row.addresseeUserId,
|
||||
}, null, { agentId: actor.agentId, userId: actor.userId });
|
||||
}
|
||||
|
||||
// An agent replacing its own still-pending card supersedes the older
|
||||
// one so the thread never accumulates stale sibling cards. This covers
|
||||
// request_confirmation drafts and ordinary task questions. Agent Chat
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
routineRuns,
|
||||
executionWorkspaces,
|
||||
issueApprovals,
|
||||
issueAccessGrants,
|
||||
issueAttachments,
|
||||
issueCreateIdempotencyKeys,
|
||||
issueInboxArchives,
|
||||
@@ -197,6 +198,7 @@ import {
|
||||
type ActivityPublication,
|
||||
} from "./activity-log.js";
|
||||
import { buildIssueChanges } from "./issue-change-receipt.js";
|
||||
import { ensurePersonalPrivateProject } from "./projects.js";
|
||||
import { projectSafeChatPublication } from "./chat-publication-projection.js";
|
||||
import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js";
|
||||
|
||||
@@ -1830,6 +1832,7 @@ export interface IssueFilters {
|
||||
includeLiveDescendantSummary?: boolean;
|
||||
hasPlanDocument?: boolean;
|
||||
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
|
||||
readCondition?: SQL<boolean>;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -3602,6 +3605,84 @@ function summarizeIssueRelationRow(
|
||||
};
|
||||
}
|
||||
|
||||
/** Inline draft images become task attachments in the same transaction as publication. */
|
||||
async function attachOwnedDraftImages(tx: DbTransaction, issue: typeof issues.$inferSelect, body: string | null | undefined,
|
||||
actor: { userId?: string | null; agentId?: string | null }) {
|
||||
const ids = [...new Set(Array.from((body ?? "").matchAll(/\/api\/assets\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/content/gi), match => match[1]!))];
|
||||
if (!ids.length || (!actor.userId && !actor.agentId)) return;
|
||||
const owned = await tx.select().from(assets).where(and(eq(assets.companyId, issue.companyId), inArray(assets.id, ids),
|
||||
actor.userId ? eq(assets.createdByUserId, actor.userId) : eq(assets.createdByAgentId, actor.agentId!)));
|
||||
for (const asset of owned) {
|
||||
if (!asset.objectKey.startsWith(`${issue.companyId}/assets/issues/drafts/`)) continue;
|
||||
await tx.insert(issueAttachments).values({ companyId: issue.companyId, issueId: issue.id, assetId: asset.id }).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAssignmentIssueAccessGrant(
|
||||
dbOrTx: any,
|
||||
issue: typeof issues.$inferSelect,
|
||||
previous: Pick<typeof issues.$inferSelect, "assigneeAgentId" | "assigneeUserId" | "visibility"> | null,
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
) {
|
||||
if (issue.visibility !== "private") return null;
|
||||
const subject = issue.assigneeAgentId
|
||||
? { subjectType: "agent" as const, subjectId: issue.assigneeAgentId }
|
||||
: issue.assigneeUserId
|
||||
? { subjectType: "user" as const, subjectId: issue.assigneeUserId }
|
||||
: null;
|
||||
if (!subject) return null;
|
||||
if (
|
||||
previous
|
||||
&& previous.visibility === "private"
|
||||
&& previous.assigneeAgentId === issue.assigneeAgentId
|
||||
&& previous.assigneeUserId === issue.assigneeUserId
|
||||
) return null;
|
||||
|
||||
const grantIssueId = issue.id;
|
||||
const lockKey = `issue-access-grant:${grantIssueId}:${subject.subjectType}:${subject.subjectId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
const active = await dbOrTx
|
||||
.select({ id: issueAccessGrants.id })
|
||||
.from(issueAccessGrants)
|
||||
.where(and(
|
||||
eq(issueAccessGrants.issueId, grantIssueId),
|
||||
eq(issueAccessGrants.subjectType, subject.subjectType),
|
||||
eq(issueAccessGrants.subjectId, subject.subjectId),
|
||||
isNull(issueAccessGrants.revokedAt),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
|
||||
if (active) return null;
|
||||
|
||||
const grant = await dbOrTx
|
||||
.insert(issueAccessGrants)
|
||||
.values({
|
||||
issueId: grantIssueId,
|
||||
...subject,
|
||||
source: "assignment",
|
||||
grantedByUserId: actor.userId ?? null,
|
||||
grantedByAgentId: actor.agentId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0]!);
|
||||
await logActivity(dbOrTx as Db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system",
|
||||
actorId: actor.agentId ?? actor.userId ?? "issue_service",
|
||||
agentId: actor.agentId ?? null,
|
||||
action: "issue_access_grant.created",
|
||||
entityType: "issue_access_grant",
|
||||
entityId: grant.id,
|
||||
details: {
|
||||
issueId: grant.issueId,
|
||||
subjectType: grant.subjectType,
|
||||
subjectId: grant.subjectId,
|
||||
source: grant.source,
|
||||
},
|
||||
});
|
||||
return grant;
|
||||
}
|
||||
|
||||
async function terminalExplicitBlockersByRoot(
|
||||
companyId: string,
|
||||
roots: IssueRelationIssueSummary[],
|
||||
@@ -4843,9 +4924,6 @@ async function listIssueReviewAttentionMap(
|
||||
}
|
||||
|
||||
const issueListSelect = {
|
||||
visibility: issues.visibility,
|
||||
privacyRootIssueId: issues.privacyRootIssueId,
|
||||
privacyParentIssueId: issues.privacyParentIssueId,
|
||||
externalConversationState: externalConversationStateSql(),
|
||||
conversationAgentId: issues.conversationAgentId,
|
||||
conversationUserId: issues.conversationUserId,
|
||||
@@ -4858,6 +4936,9 @@ const issueListSelect = {
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
visibility: issues.visibility,
|
||||
privacyRootIssueId: issues.privacyRootIssueId,
|
||||
privacyParentIssueId: issues.privacyParentIssueId,
|
||||
title: issues.title,
|
||||
titleNeedsGeneration: issues.titleNeedsGeneration,
|
||||
description: sql<string | null>`
|
||||
@@ -6197,6 +6278,7 @@ async function blockedInboxIssueConditions(
|
||||
visibleIssueCondition(),
|
||||
notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]),
|
||||
];
|
||||
if (filters?.readCondition) conditions.push(filters.readCondition);
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const inboxArchivedByUserId =
|
||||
filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||
@@ -7934,6 +8016,7 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId));
|
||||
if (filters?.readCondition) conditions.push(filters.readCondition);
|
||||
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
|
||||
filters?.assigneeAgentId,
|
||||
);
|
||||
@@ -8085,7 +8168,7 @@ export function issueService(db: Db) {
|
||||
const issueSource = db.select(issueListSelect).from(issues);
|
||||
const searchedSource = hasSearch
|
||||
? issueSource.innerJoin(sql`(
|
||||
${taskSearchCtes(companyId, taskSearch, true, and(...conditions))}
|
||||
${taskSearchCtes(companyId, taskSearch, true, and(...conditions), filters?.readCondition)}
|
||||
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
|
||||
) task_search`, sql`task_search.id = ${issues.id}`)
|
||||
: issueSource;
|
||||
@@ -8261,6 +8344,7 @@ export function issueService(db: Db) {
|
||||
}
|
||||
|
||||
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
|
||||
if (filters?.readCondition) conditions.push(filters.readCondition);
|
||||
if (!filters?.q?.trim()) {
|
||||
conditions.push(isNull(issues.conversationAgentId));
|
||||
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
||||
@@ -9851,6 +9935,7 @@ export function issueService(db: Db) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`);
|
||||
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
|
||||
if (issueData.conversationAgentId && issueData.conversationUserId) {
|
||||
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
|
||||
@@ -10196,8 +10281,64 @@ export function issueService(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
let visibility = issueData.visibility ?? "open";
|
||||
let privacyRootIssueId: string | null = null;
|
||||
// Provenance survives standalone chat handoffs. This is an access edge,
|
||||
// independent of the product's structural parent/child relationship.
|
||||
let privacyParentIssueId = issueData.parentId ?? null;
|
||||
const sourceRunId = issueData.originRunId ?? actorRunId;
|
||||
if (!privacyParentIssueId && sourceRunId) {
|
||||
const [sourceRun] = await tx.select().from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId)));
|
||||
const sourceId = sourceRun?.nativeIssueId ?? sourceRun?.issueId ?? sourceRun?.contextSnapshot?.issueId;
|
||||
if (typeof sourceId === "string") privacyParentIssueId = sourceId;
|
||||
}
|
||||
let inheritedPrivateSubtree = false;
|
||||
if (privacyParentIssueId) {
|
||||
const [source] = await tx.select().from(issues)
|
||||
.where(and(eq(issues.id, privacyParentIssueId), eq(issues.companyId, companyId)));
|
||||
if (!source) privacyParentIssueId = null;
|
||||
else if (source.visibility === "private" || (source.projectId && await tx.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, source.projectId), eq(projects.visibility, "private"))).limit(1).then(rows => rows.length > 0))) {
|
||||
visibility = "private";
|
||||
privacyRootIssueId = source.privacyRootIssueId ?? source.id;
|
||||
inheritedPrivateSubtree = true;
|
||||
}
|
||||
}
|
||||
if (visibility === "private" && !privacyRootIssueId) {
|
||||
issueData.id ??= randomUUID();
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await tx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
visibility = "private";
|
||||
if (!privacyRootIssueId) {
|
||||
issueData.id ??= randomUUID();
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibility === "private" && !issueData.projectId && !inheritedPrivateSubtree) {
|
||||
if (!responsibleUserId) {
|
||||
throw unprocessable("Private tasks require a responsible user");
|
||||
}
|
||||
const personalProject = await ensurePersonalPrivateProject(tx, companyId, responsibleUserId);
|
||||
issueData.projectId = personalProject.id;
|
||||
}
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
visibility,
|
||||
privacyRootIssueId,
|
||||
privacyParentIssueId,
|
||||
titleNeedsGeneration,
|
||||
originRunId: issueData.originRunId ?? actorRunId ?? null,
|
||||
responsibleUserId,
|
||||
@@ -10241,6 +10382,20 @@ export function issueService(db: Db) {
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
await tx.insert(issueAccessGrants).values({
|
||||
issueId: issue.id,
|
||||
subjectType: "agent",
|
||||
subjectId: issueData.createdByAgentId,
|
||||
source: "explicit",
|
||||
grantedByAgentId: issueData.createdByAgentId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await ensureAssignmentIssueAccessGrant(tx, issue, null, {
|
||||
agentId: issueData.createdByAgentId ?? null,
|
||||
userId: issueData.createdByUserId ?? null,
|
||||
});
|
||||
await recordChatHandoff(tx, issue, actorRunId);
|
||||
if (idempotencyKey) {
|
||||
await tx.insert(issueCreateIdempotencyKeys).values({
|
||||
@@ -10764,6 +10919,23 @@ export function issueService(db: Db) {
|
||||
...issueData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (issueData.visibility === "private") {
|
||||
patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id;
|
||||
} else if (issueData.visibility === "open") {
|
||||
patch.privacyRootIssueId = null;
|
||||
}
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await dbOrTx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, existing.companyId)))
|
||||
.then((rows: Array<{ visibility: string }>) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
patch.visibility = "private";
|
||||
patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id;
|
||||
}
|
||||
}
|
||||
if (existing.status !== "blocked" && issueData.status === "blocked") {
|
||||
patch.blockedTransitionAt = patch.updatedAt;
|
||||
patch.blockedOwnerNotifiedAt = null;
|
||||
@@ -10958,6 +11130,8 @@ export function issueService(db: Db) {
|
||||
}
|
||||
|
||||
const runUpdate = async (tx: any) => {
|
||||
const changesPrivacy = issueData.visibility !== undefined || issueData.parentId !== undefined || issueData.projectId !== undefined;
|
||||
if (changesPrivacy) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existing.companyId}`}, 0))`);
|
||||
// The receipt baseline must be read under the same row lock as the
|
||||
// write. Otherwise a concurrent update can be mistaken for a change
|
||||
// made by this request.
|
||||
@@ -10968,6 +11142,32 @@ export function issueService(db: Db) {
|
||||
.for("update")
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!receiptExisting) return null;
|
||||
if (changesPrivacy) {
|
||||
const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : receiptExisting.projectId;
|
||||
const [privacyProject] = nextProjectId ? await tx.select().from(projects)
|
||||
.where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId))) : [];
|
||||
if (privacyProject?.visibility === "private") {
|
||||
if (issueData.visibility === "open") {
|
||||
if (!privacyProject.personalOwnerUserId) throw unprocessable("Move this task out of its private project before making it public");
|
||||
patch.projectId = null;
|
||||
} else {
|
||||
patch.visibility = "private";
|
||||
patch.privacyRootIssueId = receiptExisting.privacyRootIssueId ?? receiptExisting.id;
|
||||
}
|
||||
}
|
||||
const privacyParentId = issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId;
|
||||
const [privacyParent] = privacyParentId ? await tx.select().from(issues)
|
||||
.where(and(eq(issues.id, privacyParentId), eq(issues.companyId, existing.companyId))) : [];
|
||||
patch.privacyParentIssueId = privacyParent?.id ?? null;
|
||||
if (privacyParent && (privacyParent.visibility === "private" || (privacyParent.projectId && await tx.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, privacyParent.projectId), eq(projects.visibility, "private"))).limit(1).then((rows: Array<{ id: string }>) => rows.length > 0)))) {
|
||||
if (issueData.visibility === "open") throw unprocessable("A task cannot be made public while it inherits private access");
|
||||
patch.visibility = "private";
|
||||
patch.privacyRootIssueId = privacyParent.privacyRootIssueId ?? privacyParent.id;
|
||||
} else if (issueData.visibility === "private") {
|
||||
patch.privacyRootIssueId = receiptExisting.id;
|
||||
}
|
||||
}
|
||||
if (actorAgentId && actorRunId) {
|
||||
// Recheck under a run lock: a request admitted before Stop must not
|
||||
// commit a late Done after cancellation revoked its credentials.
|
||||
@@ -11040,6 +11240,33 @@ export function issueService(db: Db) {
|
||||
.returning()
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
if (issueData.description !== undefined) await attachOwnedDraftImages(tx, updated, updated.description,
|
||||
{ userId: actorUserId, agentId: actorAgentId });
|
||||
if (changesPrivacy && updated.visibility === "private") {
|
||||
// Creation and tree changes hold the same company lock. No child can
|
||||
// slip into the tree between discovering descendants and protecting them.
|
||||
const protectedRows = await tx.execute(sql`with recursive descendants as (
|
||||
select i.id, i.parent_id, i.privacy_parent_issue_id from issues i
|
||||
where i.id = ${updated.id} and i.company_id = ${updated.companyId}
|
||||
union
|
||||
select i.id, i.parent_id, i.privacy_parent_issue_id from issues i join descendants p
|
||||
on i.parent_id = p.id or i.privacy_parent_issue_id = p.id
|
||||
where i.company_id = ${updated.companyId}
|
||||
) update issues i set visibility = 'private',
|
||||
privacy_root_issue_id = ${updated.privacyRootIssueId ?? updated.id},
|
||||
privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id), updated_at = now()
|
||||
from descendants d where i.id = d.id and i.id <> ${updated.id} returning i.id`);
|
||||
if (protectedRows.length) {
|
||||
const descendants = await tx.select().from(issues).where(inArray(issues.id, protectedRows.map((row: { id: string }) => row.id)));
|
||||
for (const descendant of descendants) await ensureAssignmentIssueAccessGrant(tx, descendant, null, {
|
||||
agentId: actorAgentId ?? null, userId: actorUserId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
await ensureAssignmentIssueAccessGrant(tx, updated, receiptExisting, {
|
||||
agentId: actorAgentId ?? null,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
await recordChatCompletion(tx, receiptExisting, updated);
|
||||
// An operator explicitly choosing a disposition owns that decision,
|
||||
// including choosing In Review while the conversation is Idle.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, issueThreadInteractions, statusDecisions, type Db } from "@paperclipai/db";
|
||||
import type { ExecutionContinuationEnvelope } from "@paperclipai/shared";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "../authorization.js";
|
||||
|
||||
/** Recorded decisions are evidence for the parent, never new review authority. */
|
||||
export async function childReviewOutcomes(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
parentIssueId: string,
|
||||
actor?: AuthorizationActor,
|
||||
): Promise<ExecutionContinuationEnvelope["interactionOutcomes"]> {
|
||||
const rows = await db.select({
|
||||
id: issueThreadInteractions.id,
|
||||
@@ -40,6 +42,7 @@ export async function childReviewOutcomes(
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.parentId, parentIssueId),
|
||||
actor ? await issueReadSqlCondition(db, actor) : undefined,
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
inArray(issueThreadInteractions.status, ["accepted", "rejected"]),
|
||||
sql`${issueThreadInteractions.payload}->'target'->>'type' = 'custom'`,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { and, desc, eq, lte } from "drizzle-orm";
|
||||
import { documentRevisions, heartbeatRuns, issueDocuments, issues, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
|
||||
import { canActorReadIssuePrivacy, type AuthorizationActor } from "../authorization.js";
|
||||
|
||||
/** Approval evidence belongs to the source conversation and exact revision.
|
||||
* It informs scope; it does not approve the new task's document or waive gates. */
|
||||
export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelect) {
|
||||
export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelect, actor?: AuthorizationActor) {
|
||||
if (!task.originRunId || task.conversationAgentId) return null;
|
||||
const [sourceRun] = await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, task.originRunId), eq(heartbeatRuns.companyId, task.companyId),
|
||||
@@ -12,6 +14,7 @@ export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelec
|
||||
if (typeof sourceId !== "string" || sourceId === task.id) return null;
|
||||
const [source] = await db.select().from(issues).where(and(eq(issues.id, sourceId), eq(issues.companyId, task.companyId)));
|
||||
if (!source?.conversationAgentId) return null;
|
||||
if (actor && !(await canActorReadIssuePrivacy(db, actor, source))) return null;
|
||||
const accepted = await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, task.companyId), eq(issueThreadInteractions.issueId, source.id),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"),
|
||||
|
||||
@@ -1285,16 +1285,15 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
it("captures delegation and approval origins before steering and preserves replay identity", async () => {
|
||||
const issueId = "00000000-0000-4000-8000-000000000120";
|
||||
const runId = "00000000-0000-4000-8000-000000000121";
|
||||
for (const userId of ["person-a", "person-b"]) {
|
||||
await db.insert(authUsers).values({ id: userId, name: userId, email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
||||
}
|
||||
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Identity delegation",
|
||||
status: "in_progress", assigneeAgentId: agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: issueId,
|
||||
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
await db.insert(authUsers).values(["person-a", "person-b"].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
||||
await db.insert(companyMemberships).values(["person-a", "person-b"].map(principalId => ({ companyId, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
|
||||
const origin = await initializeRunIdentity(db, {
|
||||
companyId, runId, issueId, responsibleUserId: "person-a", cause: "instruction",
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { agentCommentaryToolInputSchema } from "@paperclipai/shared";
|
||||
import { submitAgentCommentary } from "../agent-commentary.js";
|
||||
import type { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import { assertAssignableAgent } from "../agent-assignability.js";
|
||||
import { authorizationService } from "../authorization.js";
|
||||
import { approvalReadSqlCondition, canActorReadApproval, authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../authorization.js";
|
||||
import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
||||
import { buildLowTrustSourceTrust } from "../source-trust.js";
|
||||
@@ -482,6 +482,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
eq(issues.companyId, this.binding.companyId),
|
||||
eq(issues.parentId, this.binding.issueId),
|
||||
isNull(issues.hiddenAt),
|
||||
await issueReadSqlCondition(this.db, this.#privacyActor(context.run)),
|
||||
))
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id))
|
||||
.limit(101);
|
||||
@@ -502,8 +503,8 @@ export class PaperclipRunnerToolAuthority {
|
||||
},
|
||||
connectionGuidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot),
|
||||
sourcePlanApproval: await handoffPlanContext(this.db, context.issue),
|
||||
childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId),
|
||||
sourcePlanApproval: await handoffPlanContext(this.db, context.issue, this.#privacyActor(context.run)),
|
||||
childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId, this.#privacyActor(context.run)),
|
||||
...(this.binding.nativeReview ? {
|
||||
assignedReview: (await getNativeReviewAssignment(this.db, {
|
||||
...this.binding, contextSnapshot: this.binding.nativeReview,
|
||||
@@ -531,7 +532,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
return { comments: comments.reverse() };
|
||||
}
|
||||
case "search_tasks": {
|
||||
const tasks = await issueService(this.db).list(this.binding.companyId);
|
||||
const tasks = await issueService(this.db).list(this.binding.companyId, { readCondition: await issueReadSqlCondition(this.db, this.#privacyActor(context.run)) });
|
||||
const query = typeof input.query === "string" ? input.query.toLowerCase() : "";
|
||||
const statuses = Array.isArray(input.statuses) ? new Set(input.statuses.filter((value): value is string => typeof value === "string")) : null;
|
||||
return { tasks: tasks.filter((task) =>
|
||||
@@ -557,7 +558,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
return { actor: redactedActor(actor) };
|
||||
}
|
||||
case "list_approvals":
|
||||
return { approvals: await approvalService(this.db).list(this.binding.companyId) };
|
||||
return { approvals: await approvalService(this.db).list(this.binding.companyId, undefined, await approvalReadSqlCondition(this.db, this.#privacyActor(context.run))) };
|
||||
case "get_approval": {
|
||||
const approval = await this.#approval(requiredString(input.approvalId));
|
||||
return { approval };
|
||||
@@ -570,6 +571,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
eq(issueApprovals.approvalId, approval.id),
|
||||
eq(issueApprovals.companyId, this.binding.companyId),
|
||||
eq(issues.companyId, this.binding.companyId),
|
||||
await issueReadSqlCondition(this.db, this.#privacyActor(context.run)),
|
||||
));
|
||||
return { approval, tasks: tasks.map((row) => row.issue) };
|
||||
}
|
||||
@@ -783,9 +785,16 @@ export class PaperclipRunnerToolAuthority {
|
||||
async #approval(id: string) {
|
||||
const approval = await approvalService(this.db).getById(id);
|
||||
if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found");
|
||||
const context = await this.#boundContext();
|
||||
if (!(await canActorReadApproval(this.db, this.#privacyActor(context.run), id))) throw new Error("paperclip_runner_approval_not_found");
|
||||
return approval;
|
||||
}
|
||||
|
||||
#privacyActor(run: { responsibleUserId: string | null }): AuthorizationActor {
|
||||
return { type: "agent", agentId: this.binding.agentId, companyId: this.binding.companyId,
|
||||
runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId };
|
||||
}
|
||||
|
||||
async #boundContext() {
|
||||
const [row] = await this.db.select({ issue: issues, actor: agents, run: heartbeatRuns })
|
||||
.from(heartbeatRuns)
|
||||
@@ -826,6 +835,9 @@ export class PaperclipRunnerToolAuthority {
|
||||
throw forbidden("The assigned review is no longer available to this run.");
|
||||
}
|
||||
}
|
||||
const privacy = await authorizationService(this.db).decide({ actor: this.#privacyActor(row.run), action: "issue:read",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, issueId: row.issue.id } });
|
||||
if (!privacy.allowed) throw forbidden("The task is no longer available to this run");
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
PluginIssueOrchestrationSummary,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, IssueRelationIssueSummary, PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||
import { companyService } from "./companies.js";
|
||||
import { agentService } from "./agents.js";
|
||||
@@ -77,7 +77,14 @@ import { isIP } from "node:net";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import {
|
||||
authorizationService,
|
||||
canActorReadIssuePrivacy,
|
||||
issuePrivacyMode,
|
||||
issueReadSqlCondition,
|
||||
type AuthorizationActor,
|
||||
type IssuePrivacyRow,
|
||||
} from "./authorization.js";
|
||||
import { redactEventPayload, sanitizeRecord } from "../redaction.js";
|
||||
import type { WorkerHostCallContext } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
@@ -760,6 +767,135 @@ export function buildHostServices(
|
||||
const scopedBus = eventBus.forPlugin(pluginKey);
|
||||
const lifecycleInbox = pluginLifecycleInbox(db, pluginId);
|
||||
|
||||
// --- Issue privacy for plugin-facing reads (PAP-16091 / PAP-16050) ---------
|
||||
// Plugins are non-member principals: they hold no company membership, issue
|
||||
// access grant, or private-project access, so the canonical issue-visibility
|
||||
// predicate resolves them to the same scope as any other non-member — public
|
||||
// issues only. Notification/digest plugins (e.g. a Slack notifier) fan issue
|
||||
// data out to shared channels whose audience is, by definition, non-members,
|
||||
// so private issue content and relationship metadata must never reach them.
|
||||
//
|
||||
// A synthetic { type: "none" } actor produces exactly that scope: it yields a
|
||||
// null privacy principal (no implicit/grant/project-member match) and does not
|
||||
// trip the local-implicit / instance-admin bypasses inside the predicate. In
|
||||
// "off"/"shadow" modes these helpers are pass-through, matching every other
|
||||
// enforcement surface so the enforce flip stays the single behavioral switch.
|
||||
const pluginIssuePrivacyActor: AuthorizationActor = { type: "none" };
|
||||
const toIssuePrivacyRow = (issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
visibility?: string | null;
|
||||
privacyRootIssueId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
}): IssuePrivacyRow => ({
|
||||
id: issue.id,
|
||||
companyId: issue.companyId,
|
||||
visibility: issue.visibility ?? "open",
|
||||
privacyRootIssueId: issue.privacyRootIssueId ?? null,
|
||||
responsibleUserId: issue.responsibleUserId ?? null,
|
||||
createdByUserId: issue.createdByUserId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
projectId: issue.projectId ?? null,
|
||||
});
|
||||
const pluginCanReadIssue = async (
|
||||
issue: Parameters<typeof toIssuePrivacyRow>[0] | null | undefined,
|
||||
): Promise<boolean> => {
|
||||
if (!issue) return false;
|
||||
if (issuePrivacyMode() !== "enforce") return true;
|
||||
return canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue));
|
||||
};
|
||||
const pluginReadableIssueIds = async (
|
||||
issueList: Array<Parameters<typeof toIssuePrivacyRow>[0]>,
|
||||
): Promise<Set<string>> => {
|
||||
if (issuePrivacyMode() !== "enforce") return new Set(issueList.map((issue) => issue.id));
|
||||
const decisions = await Promise.all(
|
||||
issueList.map(async (issue) => ({
|
||||
id: issue.id,
|
||||
allowed: await canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)),
|
||||
})),
|
||||
);
|
||||
return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id));
|
||||
};
|
||||
// Column projection matching IssuePrivacyRow, for ad-hoc readability lookups
|
||||
// of bare issue IDs (relation targets, subtree descendants) that arrive
|
||||
// without their privacy columns loaded.
|
||||
const issuePrivacyRowColumns = {
|
||||
id: issuesTable.id,
|
||||
companyId: issuesTable.companyId,
|
||||
visibility: issuesTable.visibility,
|
||||
privacyRootIssueId: issuesTable.privacyRootIssueId,
|
||||
responsibleUserId: issuesTable.responsibleUserId,
|
||||
createdByUserId: issuesTable.createdByUserId,
|
||||
assigneeUserId: issuesTable.assigneeUserId,
|
||||
assigneeAgentId: issuesTable.assigneeAgentId,
|
||||
projectId: issuesTable.projectId,
|
||||
} as const;
|
||||
// Resolve which of the given issue IDs a non-member plugin may read. Loads
|
||||
// the privacy-relevant columns for those IDs and evaluates the canonical
|
||||
// predicate. An ID with no surviving row (deleted, cross-company) is absent
|
||||
// from the result set — fail closed.
|
||||
const pluginReadableIssueIdsByIds = async (
|
||||
companyId: string,
|
||||
ids: Iterable<string>,
|
||||
): Promise<Set<string>> => {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (issuePrivacyMode() !== "enforce" || uniqueIds.length === 0) {
|
||||
return new Set(uniqueIds);
|
||||
}
|
||||
const rows = await db
|
||||
.select(issuePrivacyRowColumns)
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, uniqueIds)));
|
||||
return pluginReadableIssueIds(rows);
|
||||
};
|
||||
// Fetch an issue and require it be both in-company and readable by a
|
||||
// non-member plugin. A private (unreadable) issue throws the same "not found"
|
||||
// error a genuinely missing issue would, so its existence never leaks. This
|
||||
// is the single gate every issue-derived plugin read routes through, so no
|
||||
// method needs its own bespoke visibility check.
|
||||
const requirePluginReadableIssue = async (issueId: string, companyId: string): Promise<Issue> => {
|
||||
const issue = requireInCompany("Issue", await issues.getById(issueId), companyId);
|
||||
if (!(await pluginCanReadIssue(issue))) {
|
||||
throw new Error("Issue not found");
|
||||
}
|
||||
return issue as Issue;
|
||||
};
|
||||
// Redact a relation summary so blocker/blocked edges pointing at an issue the
|
||||
// plugin cannot read are dropped, recursing into nested terminal blockers. An
|
||||
// edge's title/status/assignee is relationship metadata a non-member must not
|
||||
// see even when the issue that owns the edge is itself public.
|
||||
const pluginRedactRelationSummary = async <
|
||||
R extends { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
||||
>(
|
||||
companyId: string,
|
||||
relation: R,
|
||||
): Promise<R> => {
|
||||
if (issuePrivacyMode() !== "enforce") return relation;
|
||||
const edgeIds = new Set<string>();
|
||||
const collect = (edges: IssueRelationIssueSummary[]) => {
|
||||
for (const edge of edges) {
|
||||
edgeIds.add(edge.id);
|
||||
if (edge.terminalBlockers?.length) collect(edge.terminalBlockers);
|
||||
}
|
||||
};
|
||||
collect(relation.blockedBy);
|
||||
collect(relation.blocks);
|
||||
if (edgeIds.size === 0) return relation;
|
||||
const readable = await pluginReadableIssueIdsByIds(companyId, edgeIds);
|
||||
const redact = (edges: IssueRelationIssueSummary[]): IssueRelationIssueSummary[] =>
|
||||
edges
|
||||
.filter((edge) => readable.has(edge.id))
|
||||
.map((edge) => (edge.terminalBlockers?.length
|
||||
? { ...edge, terminalBlockers: redact(edge.terminalBlockers) }
|
||||
: edge));
|
||||
return { ...relation, blockedBy: redact(relation.blockedBy), blocks: redact(relation.blocks) };
|
||||
};
|
||||
|
||||
// Track active session event subscriptions for cleanup
|
||||
const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
let disposed = false;
|
||||
@@ -1912,13 +2048,21 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
assertReadableOriginFilter(params.originKind);
|
||||
return applyWindow((await issues.list(companyId, params as any)) as Issue[], params);
|
||||
// Push the non-member visibility predicate into the list query so private
|
||||
// issues are filtered at the SQL layer before they can reach a plugin.
|
||||
const readCondition = await issueReadSqlCondition(db, pluginIssuePrivacyActor);
|
||||
return applyWindow(
|
||||
(await issues.list(companyId, { ...(params as any), readCondition })) as Issue[],
|
||||
params,
|
||||
);
|
||||
},
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const issue = await issues.getById(params.issueId);
|
||||
return (inCompany(issue, companyId) ? issue : null) as Issue | null;
|
||||
if (!inCompany(issue, companyId)) return null;
|
||||
if (!(await pluginCanReadIssue(issue))) return null;
|
||||
return issue as Issue;
|
||||
},
|
||||
async create(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -1929,6 +2073,17 @@ export function buildHostServices(
|
||||
? pluginOperationIssueOriginKind(pluginKey)
|
||||
: originKind,
|
||||
);
|
||||
if (issueInput.parentId) await requirePluginReadableIssue(issueInput.parentId, companyId);
|
||||
const sourceRunId = params.originRunId ?? actorRunId;
|
||||
if (sourceRunId) {
|
||||
const [sourceRun] = await db.select().from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId)));
|
||||
if (!sourceRun) throw new Error("Origin run not found");
|
||||
if (sourceRun.scopeKind !== "company") {
|
||||
if (!sourceRun.issueId) throw new Error("Origin run not found");
|
||||
await requirePluginReadableIssue(sourceRun.issueId, companyId);
|
||||
}
|
||||
}
|
||||
const issue = (await issues.create(companyId, {
|
||||
...(issueInput as any),
|
||||
originKind: normalizedOriginKind,
|
||||
@@ -1960,6 +2115,7 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
await requirePluginReadableIssue(params.issueId, companyId);
|
||||
const patch = { ...(params.patch as Record<string, unknown>) };
|
||||
const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null;
|
||||
const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null;
|
||||
@@ -1996,13 +2152,20 @@ export function buildHostServices(
|
||||
async getRelations(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
return await issues.getRelationSummaries(params.issueId);
|
||||
// Private root -> indistinguishable from missing; readable root ->
|
||||
// still redact any edge that points at an issue the plugin cannot read.
|
||||
await requirePluginReadableIssue(params.issueId, companyId);
|
||||
return await pluginRedactRelationSummary(
|
||||
companyId,
|
||||
await issues.getRelationSummaries(params.issueId),
|
||||
);
|
||||
},
|
||||
async setBlockedBy(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return setBlockedByWithActivity({
|
||||
// The echoed relation summary can name a private blocker target, so
|
||||
// redact unreadable edges before returning it to a non-member plugin.
|
||||
return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: params.blockedByIssueIds,
|
||||
@@ -2010,7 +2173,7 @@ export function buildHostServices(
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
}));
|
||||
},
|
||||
async addBlockers(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -2023,7 +2186,7 @@ export function buildHostServices(
|
||||
...params.blockerIssueIds,
|
||||
]),
|
||||
];
|
||||
return setBlockedByWithActivity({
|
||||
return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: nextBlockedByIssueIds,
|
||||
@@ -2031,7 +2194,7 @@ export function buildHostServices(
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
}));
|
||||
},
|
||||
async removeBlockers(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -2042,7 +2205,7 @@ export function buildHostServices(
|
||||
const nextBlockedByIssueIds = previous.blockedBy
|
||||
.map((relation) => relation.id)
|
||||
.filter((issueId) => !removals.has(issueId));
|
||||
return setBlockedByWithActivity({
|
||||
return pluginRedactRelationSummary(companyId, await setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: nextBlockedByIssueIds,
|
||||
@@ -2050,7 +2213,7 @@ export function buildHostServices(
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
}));
|
||||
},
|
||||
async assertCheckoutOwner(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -2089,17 +2252,31 @@ export function buildHostServices(
|
||||
async getSubtree(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
// A private root is invisible to a non-member plugin: report not-found
|
||||
// rather than leak its existence or any descendant metadata.
|
||||
const rootIssue = await requirePluginReadableIssue(params.issueId, companyId);
|
||||
const includeRoot = params.includeRoot !== false;
|
||||
const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id);
|
||||
const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id);
|
||||
const issueRows = issueIds.length > 0
|
||||
const candidateIssueIds = includeRoot
|
||||
? subtreeIssueIds
|
||||
: subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id);
|
||||
const issueRows = candidateIssueIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds)))
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, candidateIssueIds)))
|
||||
: [];
|
||||
const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue]));
|
||||
// Filter private descendants BEFORE fetching any relations, documents,
|
||||
// runs, or assignees so private issue rows and their metadata never
|
||||
// enter the result. The root has already cleared the readability gate.
|
||||
const readableSubtreeIds = await pluginReadableIssueIds(issueRows);
|
||||
readableSubtreeIds.add(rootIssue.id);
|
||||
const issueIds = candidateIssueIds.filter((issueId) => readableSubtreeIds.has(issueId));
|
||||
const issuesById = new Map(
|
||||
issueRows
|
||||
.filter((issue) => readableSubtreeIds.has(issue.id))
|
||||
.map((issue) => [issue.id, issue as Issue]),
|
||||
);
|
||||
const outputIssues = issueIds
|
||||
.map((issueId) => issuesById.get(issueId))
|
||||
.filter((issue): issue is Issue => Boolean(issue));
|
||||
@@ -2110,7 +2287,10 @@ export function buildHostServices(
|
||||
|
||||
const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([
|
||||
params.includeRelations
|
||||
? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const))
|
||||
? Promise.all(issueIds.map(async (issueId) => [
|
||||
issueId,
|
||||
await pluginRedactRelationSummary(companyId, await issues.getRelationSummaries(issueId)),
|
||||
] as const))
|
||||
: Promise.resolve(null),
|
||||
params.includeDocuments
|
||||
? Promise.all(
|
||||
@@ -2308,12 +2488,27 @@ export function buildHostServices(
|
||||
async getOrchestrationSummary(params): Promise<PluginIssueOrchestrationSummary> {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const subtreeIssueIds = params.includeSubtree
|
||||
// A private root is invisible to a non-member plugin: report not-found
|
||||
// rather than leak its existence or any subtree relationship metadata.
|
||||
const rootIssue = await requirePluginReadableIssue(params.issueId, companyId);
|
||||
const rawSubtreeIssueIds = params.includeSubtree
|
||||
? await collectIssueSubtreeIds(companyId, rootIssue.id)
|
||||
: [rootIssue.id];
|
||||
// Drop any private descendant the plugin may not read so its relations,
|
||||
// approvals, runs, costs, and budget blocks never enter the summary.
|
||||
const readableSubtreeIds = await pluginReadableIssueIdsByIds(companyId, rawSubtreeIssueIds);
|
||||
readableSubtreeIds.add(rootIssue.id);
|
||||
const subtreeIssueIds = rawSubtreeIssueIds.filter((id) => readableSubtreeIds.has(id));
|
||||
// Blocker edges can point at private issues outside the readable subtree
|
||||
// (e.g. a public task blocked by a private one). Their title/status/
|
||||
// assignee are relationship metadata a non-member must not see, so
|
||||
// pluginRedactRelationSummary drops any edge whose target is unreadable,
|
||||
// recursing into nested terminal blockers.
|
||||
const relationPairs = await Promise.all(
|
||||
subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const),
|
||||
subtreeIssueIds.map(async (issueId) => [
|
||||
issueId,
|
||||
await pluginRedactRelationSummary(companyId, await issues.getRelationSummaries(issueId)),
|
||||
] as const),
|
||||
);
|
||||
const approvalRows = (
|
||||
await Promise.all(
|
||||
@@ -2383,7 +2578,10 @@ export function buildHostServices(
|
||||
async listComments(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
if (!inCompany(await issues.getById(params.issueId), companyId)) return [];
|
||||
const issue = await issues.getById(params.issueId);
|
||||
if (!inCompany(issue, companyId)) return [];
|
||||
// Comment visibility follows the ACL of the issue they were posted on.
|
||||
if (!(await pluginCanReadIssue(issue))) return [];
|
||||
return (await issues.listComments(params.issueId)) as IssueComment[];
|
||||
},
|
||||
async createComment(params) {
|
||||
@@ -2498,7 +2696,11 @@ export function buildHostServices(
|
||||
async listInteractions(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
if (!inCompany(await issues.getById(params.issueId), companyId)) return [];
|
||||
const issue = await issues.getById(params.issueId);
|
||||
if (!inCompany(issue, companyId)) return [];
|
||||
// Interaction payloads follow the ACL of the issue they belong to; a
|
||||
// private issue yields no interactions to a non-member plugin.
|
||||
if (!(await pluginCanReadIssue(issue))) return [];
|
||||
return (await interactions.listForIssue(params.issueId)) as any;
|
||||
},
|
||||
async respondInteraction(params) {
|
||||
@@ -2589,7 +2791,9 @@ export function buildHostServices(
|
||||
async listAttachments(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
if (!inCompany(await issues.getById(params.issueId), companyId)) return [];
|
||||
const issue = await issues.getById(params.issueId);
|
||||
if (!inCompany(issue, companyId)) return [];
|
||||
if (!(await pluginCanReadIssue(issue))) return [];
|
||||
return (await issues.listAttachments(params.issueId)) as any;
|
||||
},
|
||||
async getAttachmentContent(params) {
|
||||
@@ -2599,6 +2803,9 @@ export function buildHostServices(
|
||||
// Unknown and cross-company ids are deliberately indistinguishable to
|
||||
// the plugin: both return null (no existence oracle across companies).
|
||||
if (!attachment || attachment.companyId !== companyId) return null;
|
||||
// A private issue's attachment content must not reach a non-member
|
||||
// plugin: gate on the parent issue's visibility, same null response.
|
||||
if (!(await pluginCanReadIssue(await issues.getById(attachment.issueId)))) return null;
|
||||
|
||||
const maxBytes = typeof params.maxBytes === "number" && params.maxBytes > 0 ? params.maxBytes : null;
|
||||
if (maxBytes !== null && attachment.byteSize > maxBytes) {
|
||||
@@ -2729,14 +2936,15 @@ export function buildHostServices(
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
// Document bodies follow the issue ACL; a private issue is not-found.
|
||||
await requirePluginReadableIssue(params.issueId, companyId);
|
||||
const rows = await documents.listIssueDocuments(params.issueId);
|
||||
return rows as any;
|
||||
},
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
await requirePluginReadableIssue(params.issueId, companyId);
|
||||
const doc = await documents.getIssueDocumentByKey(params.issueId, params.key);
|
||||
return (doc ?? null) as any;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
projects,
|
||||
projectAccessMembers,
|
||||
projectGoals,
|
||||
goals,
|
||||
issues,
|
||||
@@ -59,6 +60,69 @@ type CreateWorkspaceInput = {
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
export async function ensureProjectAccessMember(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
subjectType: "user" | "agent";
|
||||
subjectId: string;
|
||||
},
|
||||
) {
|
||||
return dbOrTx
|
||||
.insert(projectAccessMembers)
|
||||
.values(input)
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: Array<typeof projectAccessMembers.$inferSelect>) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrency-safe lazy creation for the per-user private parking project.
|
||||
* The partial unique index is the final arbiter; the advisory lock avoids
|
||||
* wasting project ids and keeps the membership insert in the same transaction.
|
||||
*/
|
||||
export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: string, userId: string) {
|
||||
const lockKey = `personal-private-project:${companyId}:${userId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
let project = await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
if (!project) {
|
||||
project = await dbOrTx
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: "My private tasks",
|
||||
description: "Private tasks visible only to people and agents explicitly granted access.",
|
||||
status: "in_progress",
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
privacyOwnerUserId: userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
project ??= await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
}
|
||||
if (!project) throw new Error("Failed to create personal private project");
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: userId,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
@@ -925,12 +989,30 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
updates.goalId = ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.update(projects)
|
||||
.set(updates)
|
||||
.where(eq(projects.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const row = await db.transaction(async (tx) => {
|
||||
if (updates.visibility !== undefined) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existingProject.companyId}`}, 0))`);
|
||||
}
|
||||
const [updated] = await tx.update(projects).set(updates).where(eq(projects.id, id)).returning();
|
||||
if (updated?.visibility === "private" && updates.visibility === "private") {
|
||||
const protectedRows = await tx.execute(sql`with recursive protected_tasks as (
|
||||
select i.id from issues i where i.project_id = ${id} and i.company_id = ${updated.companyId}
|
||||
union
|
||||
select i.id from issues i join protected_tasks p on i.parent_id = p.id or i.privacy_parent_issue_id = p.id
|
||||
where i.company_id = ${updated.companyId}
|
||||
) update issues i set visibility = 'private', privacy_root_issue_id = coalesce(i.privacy_root_issue_id, i.id),
|
||||
privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id), updated_at = now()
|
||||
from protected_tasks p where i.id = p.id returning i.id`);
|
||||
// Assignment access stays with the task after unassignment.
|
||||
if (protectedRows.length) await tx.execute(sql`insert into issue_access_grants (issue_id, subject_type, subject_id, source)
|
||||
select i.id, principal.kind, principal.id, 'assignment' from issues i
|
||||
cross join lateral (values ('agent', i.assignee_agent_id::text), ('user', i.assignee_user_id)) principal(kind, id)
|
||||
where i.id in (${sql.join(protectedRows.map(row => sql`${row.id}`), sql`, `)}) and principal.id is not null
|
||||
and not exists (select 1 from issue_access_grants g where g.issue_id = i.id
|
||||
and g.subject_type = principal.kind and g.subject_id = principal.id and g.revoked_at is null)`);
|
||||
}
|
||||
return updated ?? null;
|
||||
});
|
||||
if (!row) return null;
|
||||
|
||||
if (ids !== undefined) {
|
||||
|
||||
@@ -2662,6 +2662,8 @@ export function recoveryService(
|
||||
.values({
|
||||
companyId: input.issue.companyId,
|
||||
agentId: input.agentId,
|
||||
scopeKind: "issue",
|
||||
issueId: input.issue.id,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
costEvents,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type StatusCardActor = { agentId: string | null; userId: string | null };
|
||||
type StatusCardWriter = { agentId: string | null; runId: string | null };
|
||||
type StatusCardRow = typeof statusCards.$inferSelect;
|
||||
type StatusCardReadOptions = { issueReadCondition?: SQL<boolean> };
|
||||
|
||||
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
||||
|
||||
@@ -154,10 +155,15 @@ export function statusCardService(
|
||||
const issuesSvc = deps.issuesSvc ?? issueService(db);
|
||||
const searchSvc = companySearchService(db);
|
||||
|
||||
async function readWatchedIssueCount(card: StatusCardRow) {
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) return 0;
|
||||
async function readWatchedIssueVisibility(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) {
|
||||
return { count: 0, privacyRedacted: false };
|
||||
}
|
||||
try {
|
||||
return (await executeQueries(card)).length;
|
||||
const visible = await executeQueries(card, options);
|
||||
if (!options.issueReadCondition) return { count: visible.length, privacyRedacted: false };
|
||||
const total = await executeQueries(card);
|
||||
return { count: visible.length, privacyRedacted: total.length !== visible.length };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, cardId: card.id, companyId: card.companyId },
|
||||
@@ -167,7 +173,7 @@ export function statusCardService(
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrate(card: StatusCardRow) {
|
||||
async function hydrate(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
const dayStart = new Date();
|
||||
dayStart.setUTCHours(0, 0, 0, 0);
|
||||
const [document, today, watchedIssues] = await Promise.all([
|
||||
@@ -184,25 +190,28 @@ export function statusCardService(
|
||||
.from(statusCardUpdates)
|
||||
.where(and(eq(statusCardUpdates.cardId, card.id), gte(statusCardUpdates.startedAt, dayStart)))
|
||||
.then((rows) => rows[0] ?? { tokens: 0, costCents: 0 }),
|
||||
readWatchedIssueCount(card),
|
||||
readWatchedIssueVisibility(card, options),
|
||||
]);
|
||||
|
||||
return {
|
||||
...card,
|
||||
summaryBody: document?.latestBody ?? null,
|
||||
...(watchedIssues === undefined ? {} : { watchedIssueCount: watchedIssues }),
|
||||
summaryBody: watchedIssues?.privacyRedacted ? null : document?.latestBody ?? null,
|
||||
...(watchedIssues === undefined ? {} : {
|
||||
watchedIssueCount: watchedIssues.count,
|
||||
privacyRedacted: watchedIssues.privacyRedacted,
|
||||
}),
|
||||
todayTokens: today.tokens,
|
||||
todayCostCents: today.costCents,
|
||||
};
|
||||
}
|
||||
|
||||
async function list(companyId: string, archived: boolean) {
|
||||
async function list(companyId: string, archived: boolean, options: StatusCardReadOptions = {}) {
|
||||
const cards = await db
|
||||
.select()
|
||||
.from(statusCards)
|
||||
.where(and(eq(statusCards.companyId, companyId), archived ? isNotNull(statusCards.archivedAt) : isNull(statusCards.archivedAt)))
|
||||
.orderBy(desc(statusCards.updatedAt));
|
||||
return Promise.all(cards.map(hydrate));
|
||||
return Promise.all(cards.map((card) => hydrate(card, options)));
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
@@ -515,7 +524,11 @@ export function statusCardService(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadIssueSummaries(companyId: string, issueIds: string[]): Promise<CompanySearchIssueSummary[]> {
|
||||
async function loadIssueSummaries(
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
options: StatusCardReadOptions = {},
|
||||
): Promise<CompanySearchIssueSummary[]> {
|
||||
if (issueIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -530,7 +543,11 @@ export function statusCardService(
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.id, issueIds),
|
||||
options.issueReadCondition ?? sql<boolean>`true`,
|
||||
));
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status as CompanySearchIssueSummary["status"],
|
||||
@@ -559,15 +576,17 @@ export function statusCardService(
|
||||
return rows.map((row) => row.id).sort();
|
||||
}
|
||||
|
||||
async function listMentionedIssues(card: StatusCardRow) {
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? []);
|
||||
async function listMentionedIssues(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? [], options);
|
||||
}
|
||||
|
||||
async function executeQueries(card: StatusCardRow) {
|
||||
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
const issueMap = new Map<string, CompanySearchIssueSummary>();
|
||||
for (const storedQuery of card.queries) {
|
||||
const query = companySearchQuerySchema.parse(storedQuery);
|
||||
const response = await searchSvc.search(card.companyId, query);
|
||||
const response = await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
});
|
||||
for (const result of response.results) {
|
||||
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
|
||||
}
|
||||
@@ -577,6 +596,7 @@ export function statusCardService(
|
||||
const mentioned = await loadIssueSummaries(
|
||||
card.companyId,
|
||||
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
|
||||
options,
|
||||
);
|
||||
for (const issue of mentioned) issueMap.set(issue.id, issue);
|
||||
const snapshot = [...issueMap.values()];
|
||||
@@ -909,8 +929,16 @@ export function statusCardService(
|
||||
});
|
||||
}
|
||||
|
||||
async function dryRun(card: StatusCardRow) {
|
||||
return Promise.all(card.queries.map(async (query) => ({ query, result: await searchSvc.search(card.companyId, query) })));
|
||||
async function dryRun(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
return Promise.all(card.queries.map(async (storedQuery) => {
|
||||
const query = companySearchQuerySchema.parse(storedQuery);
|
||||
return {
|
||||
query,
|
||||
result: await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, listMentionedIssues, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
|
||||
|
||||
@@ -81,7 +81,7 @@ export function taskSearchScore(search: TaskSearch): SQL<number> {
|
||||
* Uses existing pg_trgm indexes and current rows: no derived corpus or worker.
|
||||
* The tagged comment/document sets are evaluated once, not once per task.
|
||||
*/
|
||||
export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL): SQL {
|
||||
export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL, readCondition?: SQL<boolean>): SQL {
|
||||
const n = search.tokens.length;
|
||||
const comments = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
|
||||
: sql.join(search.patterns.map((_, index) => sql`
|
||||
@@ -161,7 +161,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon
|
||||
document_matches AS MATERIALIZED (${documents}),
|
||||
literal_candidates AS MATERIALIZED (
|
||||
SELECT issues.id FROM issues
|
||||
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
|
||||
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
||||
AND ${n === 0 ? sql`${search.normalizedQuery.length === 0}` : sql`(
|
||||
${taskSearchAny(sql`issues.title`, search)}
|
||||
OR ${taskSearchAny(sql`issues.identifier`, search)}
|
||||
@@ -172,7 +172,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon
|
||||
UNION SELECT issue_id FROM document_matches
|
||||
), search_flags AS MATERIALIZED (
|
||||
${flags(sql`false`)}
|
||||
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
|
||||
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
||||
AND issues.id IN (SELECT id FROM literal_candidates)
|
||||
), literal_matches AS MATERIALIZED (
|
||||
SELECT * FROM search_flags
|
||||
@@ -184,7 +184,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon
|
||||
JOIN issues ON issues.id = literal.id
|
||||
${fallbackFilters ? sql`WHERE ${fallbackFilters}` : sql``}
|
||||
)
|
||||
AND issues.company_id = ${companyId} AND ${visibleIssueCondition()}
|
||||
AND issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
||||
AND ${fuzzy}
|
||||
), matched AS MATERIALIZED (
|
||||
SELECT * FROM literal_matches
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user