# Plainspace API extension plan — to enable the Super Productivity integration

**Audience:** the Plainspace server team (`Johannesjo/spaces`, `packages/server`).
**Author:** drafted from the SP side while wiring PR #8424 (SP ↔ Plainspace).
**Status:** proposal — three small PAT-authed endpoints + one optional polling
enhancement. No breaking changes; all additive.

> **Why this exists.** SP's Plainspace integration (PR #8424) was built against an
> _assumed_ API and is currently mock-backed. Connecting it to the real backend
> revealed that the live `/api/integration` surface supports only **read my
> assigned tasks** and **toggle done** — which covers SP's "import my tasks +
> sync completion" path, but **not** the two features in the PR's final commits:
> the **claim pool** (claim an unassigned task) and **Share on Plainspace**
> (create a space from SP). Those need new server endpoints. This document
> specifies them, grounded in the existing code so they drop into the current
> patterns.

---

## 1. What the SP client needs, and what exists today

| SP feature                             | SP API call (client)                                   | Real endpoint today                | Gap                                                |
| -------------------------------------- | ------------------------------------------------------ | ---------------------------------- | -------------------------------------------------- |
| Verify token / identity                | `getMe$`                                               | `GET /api/integration/me`          | ✅ exists                                          |
| Import my assigned tasks               | `getMyTasks$`                                          | `GET /api/integration/tasks`       | ✅ exists (filter client-side by space)            |
| Refresh one task                       | `getById$`                                             | `GET /api/integration/tasks/:id`   | ✅ exists                                          |
| Push completion back                   | done write-back                                        | `PATCH /api/integration/tasks/:id` | ✅ exists                                          |
| **Push scheduled time**                | `patchTask$ { scheduledAt }`                           | `PATCH /api/integration/tasks/:id` | ⚠️ **extend** (accept `scheduledAt`)               |
| **Read scheduled time**                | `getMyTasks$`/`getById$` → `scheduledAt`/`isRecurring` | `serializeSPTask`                  | ⚠️ **extend** (expose `scheduledAt`/`isRecurring`) |
| **Claim pool (list unclaimed)**        | `getUnclaimedTasks$`                                   | —                                  | ❌ **new endpoint**                                |
| **Claim a task (self-assign)**         | `claimTask$`                                           | —                                  | ❌ **new endpoint**                                |
| **Share on Plainspace (create space)** | `createSpace$`                                         | —                                  | ❌ **new endpoint**                                |
| Efficient polling                      | poll loop                                              | full refetch only                  | ⚠️ optional: `?updatedSince=`                      |

All three new endpoints reuse the existing `apiTokenMiddleware`, the
`loadIntegrationScope()` helper, and the `SPTask` DTO already defined in
`integration.ts`. No new auth machinery.

### Recap of the existing integration model (for context)

`apiTokenMiddleware` resolves a `pat_…` bearer token to a verified email
(blind-indexed `emailLookup`). `loadIntegrationScope(emailLookup)` then finds
every `members` row for that email where `emailVerified = true` and
`tosVersion = TOS_VERSION`, yielding the set of projects the token may act in.
A single PAT therefore spans **all** of the caller's Spaces — keep that property
for the new endpoints.

---

## 2. New endpoint 1 — Claim a task (self-assign)

```
POST /api/integration/tasks/:taskId/claim
Authorization: Bearer pat_…
→ 200 { task: SPTask }
```

Assigns an **unassigned**, not-deleted task to the caller's member in that task's
project — the server-side of SP's "Claim" button. Mirror the assignment path in
`routes/items.ts` (the `item.assigned` branch) and the transactional pattern in
the existing `PATCH /api/integration/tasks/:taskId`.

**Logic**

1. Load scope. If empty → `404 { error: 'Task not found' }`.
2. Look up the item by id, restricted to `scope.projectIds`, `isNull(deletedAt)`.
   Not found → `404`.
3. Resolve the caller's member for that project:
   `member = scope.memberByProjectId.get(item.projectId)`. Missing → `404`.
4. **Atomic claim** (handles the race where two members claim at once — same
   conditional-update pattern as the verification-code claim in `projects.ts`):

   ```ts
   const [claimed] = await tx
     .update(items)
     .set({ assignedTo: member.id })
     .where(
       and(
         eq(items.id, taskId),
         eq(items.projectId, item.projectId),
         isNull(items.assignedTo), // only if still unclaimed
         isNull(items.deletedAt),
       ),
     )
     .returning();
   if (!claimed) return c.json({ error: 'Task already claimed' }, 409); // lost the race / already assigned
   ```

5. `recordActivity(tx, { action: 'item.assigned', targetType: 'item',
targetId: taskId, memberId: member.id, meta: { text: item.text,
assignedTo: member.id, source: 'sp' } })`.
6. After commit: `sseManager.broadcast(projectId, 'item.updated',
{ item: serializeItem(claimed), memberId: member.id })` and the `activity`
   broadcast — exactly like the PATCH handler, so open web clients see the claim
   live.
7. **Do not** enqueue an `assignmentNotifications` row: claiming is a
   self-assignment, and that table already excludes self-assignments by design
   (see the comment on `assignmentNotifications` and the `assignee !== member.id`
   guard in `items.ts`). Pinging yourself about a task you just claimed is noise.
8. Return `{ task: serializeSPTask(claimed, list, proj, origin) }` (fetch `list`
   - `proj` as the PATCH handler does, or inside the tx).

**Status codes:** `200` claimed · `409` already assigned (to anyone, incl. you) ·
`404` unknown task / not a member / deleted · `401` bad token.

> **Design note — why claim is its own endpoint, not `PATCH …{ assignedTo }`.**
> Exposing arbitrary `assignedTo` over a PAT would let SP assign tasks to _other_
> members, which the integration's security model deliberately forbids (a PAT
> acts only as its own member). `claim` is the one safe, self-scoped assignment:
> it can only ever set `assignedTo = me`, and only from unassigned. This matches
> the asymmetry in `docs/plans/2026-06-02-super-productivity-integration.md`
> ("never let SP dictate assignment into a third party").

---

## 3. New endpoint 2 — List claimable (unassigned) tasks

```
GET /api/integration/claimable-tasks            → { tasks: SPTask[] }
GET /api/integration/claimable-tasks?projectId=<uuid>   (optional filter)
```

The read side of the claim pool: unassigned, not-done, not-deleted items in the
projects the caller is a member of. Same `SPTask` DTO as `/tasks`, so SP's mapper
is unchanged. Structurally identical to the existing `GET /tasks`, with the
assignment predicate inverted:

```ts
const scope = await loadIntegrationScope(emailLookup);
if (scope.memberRows.length === 0) return c.json({ tasks: [] });

const rows = await db.query.items.findMany({
  where: and(
    inArray(items.projectId, projectIdFilter ?? scope.projectIds),
    isNull(items.assignedTo), // unclaimed
    eq(items.checked, false), // not done
    isNull(items.deletedAt),
  ),
});
// then the same list/project hydration + serializeSPTask loop as GET /tasks
```

- `?projectId=` (when present) **must** be intersected with `scope.projectIds`
  so it can't be used to probe foreign projects. SP passes its bound
  `spaceId` here to avoid over-fetching unclaimed tasks from unrelated Spaces.
- Visibility is membership-based and independent of `sharingMode`: members of a
  `private` Space still see its unclaimed items (sharingMode gates _joining_,
  not member visibility).

> **Cheaper alternative if you'd rather not add a path:** a query param on the
> existing endpoint — `GET /api/integration/tasks?scope=claimable` (default
> `scope=assigned`). The dedicated path keeps `/tasks` semantics crisp and reads
> better client-side; either is fine. (This is also the natural place the
> `?updatedSince=` param from §6 would live.)

---

## 4. New endpoint 3 — Create a space (Share on Plainspace)

```
POST /api/integration/spaces
Authorization: Bearer pat_…
Body: { name: string, purpose?: string, displayName?: string }
→ 201 { project: { id, slug, name, purpose, sharingMode }, memberId }
```

Provisions a new Space owned by the PAT's email and returns the ids SP binds its
provider to (`PlainspaceCfg.spaceId = project.id`, link via `project.url`).
Mirror the `POST /api/projects` transaction in `routes/projects.ts`, minus the
email-code gate.

**Logic**

1. Decrypt the PAT's email: `decryptStoredEmail(row)` is already done in
   `apiTokenMiddleware`; expose it via `c.get('apiTokenEmail')` (the middleware
   sets it). Normalize with `normalizeEmail`.
2. Validate body with a new `CreateSpaceViaTokenSchema` (zod, in
   `packages/shared/src/validation.ts`):
   `name 1..MAX_PROJECT_NAME_LENGTH`, `purpose ≤ MAX_PURPOSE_LENGTH default ''`,
   `displayName 1..MAX_DISPLAY_NAME_LENGTH` (default to the email local-part if
   omitted). `safeParse` failure → `422` with `details: error.flatten()`.
3. Transaction (mirrors `projects.ts`):
   ```ts
   const slug = nanoid(SLUG_LENGTH);
   const [project] = await tx
     .insert(projects)
     .values({ slug, name, purpose })
     .returning();
   const [member] = await tx
     .insert(members)
     .values({
       projectId: project.id,
       tokenHash: hashToken(nanoid(TOKEN_LENGTH)), // web-session token; unused by SP, see note
       displayName,
       ...encryptedEmailFields(memberEmail),
       emailVerified: true, // the PAT already proves email ownership
       color: MEMBER_COLORS[0],
       avatarIndex: 0,
       isCreator: true,
       role: 'admin',
       tosVersion: TOS_VERSION,
       tosAcceptedAt: new Date(),
     })
     .returning();
   await ensureProjectDefaults(tx, { projectId: project.id, memberId: member.id });
   ```
4. Return `201 { project: serializeProject(project), memberId: member.id }`.

**Why no email-verification code here.** `POST /api/projects` gates creation on a
6-digit emailed code _or_ a `proofToken` from an existing Space (see
`resolveProofEmail` / `proofVerified`). A valid PAT is a strictly stronger proof
of the same email ownership — it was minted (`api-tokens.ts`) only after that
email was verified inside a Space. So `emailVerified: true` and skipping the code
is consistent with the existing `proofToken` shortcut, not a new trust
assumption.

**The new member's token.** SP does **not** need the returned web-session
`token`: because the new member shares the PAT's `emailLookup`, is
`emailVerified`, and carries the current `tosVersion`, `loadIntegrationScope`
**immediately** includes the new Space for the same PAT. So the existing PAT can
read/claim/patch in the new Space with no re-auth. Returning `memberId` is enough;
omit the session token (or return it for parity — your call).

**Rate limiting.** `POST /api/projects` is IP-rate-limited + code-gated. This
endpoint has neither, so add a per-email cap to stop a leaked PAT from mass-
creating Spaces — e.g. `checkRateLimit('create-space-token:' + apiTokenId, N,
window)` (reuse `lib/rate-limit.ts`). Suggest something conservative (e.g. 10 /
hour).

**Status codes:** `201` created · `422` validation · `429` rate-limited ·
`401` bad token.

---

## 4b. Scheduled time — expose `scheduledAt` / `isRecurring`, accept `scheduledAt`

SP syncs a task's **scheduled time** (`task.dueWithTime`) to a Plainspace item's
existing `remindAt` column. This is **not a new endpoint** — it extends the
`SPTask` DTO (read) and the existing `PATCH /tasks/:id` (write). No new tables;
`items.remindAt` + `items.repeat` and the whole reminder/repeat machinery already
exist. The DTO uses **SP-facing names** that map to those columns:

| DTO field (`SPTask`)          | DB column              | Meaning                                        |
| ----------------------------- | ---------------------- | ---------------------------------------------- |
| `scheduledAt: string \| null` | `items.remindAt`       | ISO instant the task is scheduled for, or null |
| `isRecurring: boolean`        | `items.repeat != null` | whether it repeats (cadence stays server-side) |

### Read — add `scheduledAt` + `isRecurring` to `serializeSPTask`

```ts
return {
  // …existing fields…
  scheduledAt: item.remindAt ? item.remindAt.toISOString() : null,
  isRecurring: item.repeat != null,
};
```

So `getMyTasks$`/`getById$`/`claimable-tasks` all carry them. `isRecurring` is the
yes/no flag SP needs to surface recurrence; the rule itself never crosses the wire.

### Write — accept `scheduledAt` on `PATCH /tasks/:id`

Today the integration PATCH only accepts `{ done: boolean }`. Widen its body
schema to also accept `scheduledAt` (mapped to the `remindAt` column):

```
PATCH /api/integration/tasks/:taskId
Body: { done?: boolean, scheduledAt?: string | null }   // ISO instant, or null to unschedule
```

- Apply the **same `remindAt`/`repeat`/`anchor` invariants** the in-app PATCH uses
  (`applyRepeatUpdate` in `items.ts`): clearing `scheduledAt` (→ `remindAt = null`)
  cascades to `repeat:null`; re-scheduling a repeating item re-anchors the rule.
  SP never sends a rule, so a `scheduledAt`-only PATCH on a repeating item hits
  exactly the "re-anchor existing rule" branch.
- Still **member-scoped**: the PAT can only patch items in `scope.projectIds`,
  and only ever its own caller's item — same guard as the done write-back. SP
  setting `scheduledAt` is self-scoped scheduling, not assignment.
- Validation: reject a `scheduledAt` that isn't a valid ISO instant or `null`
  (`422`).

### Recurrence — server stays authoritative, SP just tracks `scheduledAt`

Deliberately **no rule translation** between Plainspace `RepeatRule` and SP's
`TaskRepeatCfg` (different execution models — Plainspace = one persistent row the
sweep advances; SP = a template that spawns instances — and SP's recurrence is
mid-refactor). Instead:

- **Plainspace → SP:** a repeating item imports as a single ordinary SP task with
  `dueWithTime = scheduledAt` (the next occurrence); `isRecurring` flags it. When
  the sweep advances `remindAt`, SP's poll re-pulls the new `scheduledAt` and
  reschedules the same task. SP needs zero knowledge of the rule.
- **SP → Plainspace:** SP only ever PATCHes a concrete `scheduledAt` (never a
  rule). An SP-recurring task pushes each occurrence as a one-off; Plainspace
  keeps owning any rule it created.

> Behavior to expect (not a bug): completing an imported **recurring** task in SP
> write-backs `done`, the sweep then advances + un-checks the item, and SP's next
> poll reopens it at the new time. Correct for a recurring item; the
> done-write-back ↔ scheduledAt-re-pull interaction wants an idempotency test.

### Client scope in PR #8424

The SP side imports `scheduledAt → dueWithTime` on task add (schedule shows in the
app, with `isRecurring` available to flag recurrence) and pushes
`dueWithTime → scheduledAt` (incl. `null` on unschedule), mirroring the done
write-back. The recurrence-tracking poll extension (re-pull an advanced
`scheduledAt`) is a documented follow-up. `dueDay` (date-only SP scheduling) is
intentionally **not** synced — `scheduledAt` always carries a time, so mapping a
day-only task would fabricate one.

---

## 4c. New endpoint 4 — Create a task (add directly to Plainspace)

```
POST /api/integration/tasks
Authorization: Bearer pat_…
body: { spaceId: string, title: string }   // title 1–500 chars
→ 201 { task: SPTask }
```

The symmetric twin of `claimTask$`: it lets a task added to a Plainspace-backed
SP project appear for the team. When SP adds a task to a project that has a bound
`PLAINSPACE` provider, it POSTs here and links the returned `SPTask.id` back to
the local task (then the existing done/title/`scheduledAt` write-back keeps it in
sync). `spaceId` accepts the project **UUID or slug** (same as everywhere SP
holds `cfg.spaceId`); resolve it against `scope.projectIds` so a token can't
create tasks in a foreign Space.

This is the PAT-authed integration-API equivalent of the existing member-token
route `POST /api/projects/:slug/items` — reuse that route's item-creation logic
(append at end of the project's primary/hero list, `recordActivity`, broadcast
`item.created` over SSE), just behind `apiTokenMiddleware` + `loadIntegrationScope`
and returning the `SPTask` DTO instead of the internal activity entry. The
member-token route is **not** reusable from SP directly: the SP client only ever
holds a PAT (`pat_…`), never a per-session member token.

```ts
const { spaceId, title } = c.req.valid('json'); // CreateTaskViaTokenSchema
const scope = await loadIntegrationScope(emailLookup);
const projectId = resolveScopedProjectId(spaceId, scope); // UUID or slug → UUID, or 404
// then the same insert path as POST /api/projects/:slug/items (primary list,
// position = max+GAP), serializeSPTask(row) → c.json({ task }, 201)
```

- **Status codes:** `201` created · `422` validation · `404` unknown/foreign
  `spaceId` · `401`/`428` auth (same as the other integration routes).
- No new table. `title` maps to the item `text` column (`MAX_ITEM_TEXT_LENGTH`).
- SP lets create errors propagate (unlike its fail-soft reads) so a failed add
  surfaces a snack instead of silently dropping the task.

**Client side is already implemented** (this repo): `PlainspaceApiService.createTask$`,
`PlainspaceSyncAdapterService.createIssue`, and `_hasAutoCreateEnabled` recognising
the native `PLAINSPACE` key (no opt-in flag — the bound provider is the opt-in).
It wires into the generic `autoCreateIssueOnTaskAdd$` effect and is inert until
this endpoint ships.

---

## 5. Onboarding caveat (important for the SP "Share" UX)

A PAT can only be minted from **inside an existing Space**
(`POST /api/projects/:slug/auth/api-tokens` requires a logged-in, email-verified
member). Therefore:

- **Creating an _additional_ Space from SP** (the user already has a PAT) →
  fully covered by §4. ✅
- **Creating a user's _first_ Space from SP** (no Space, no PAT yet) → **not**
  possible with PAT-only, because there's nothing to mint a PAT from. This is the
  chicken-and-egg the **device-code flow** in
  `docs/plans/2026-06-02-super-productivity-integration.md` ("Auth: device-code,
  not copy-paste") is meant to solve. Until that lands, SP should gate "Share on
  Plainspace" behind "paste a PAT" (i.e. the user is already a Plainspace member)
  and word the empty state accordingly.

Recommend scoping device-code as its own follow-up (the SP doc already proposes
the `POST /api/integration/device-code` + `…/device-token` pair). It is the
single biggest UX unlock but is independent of the three endpoints above.

---

## 6. Optional — efficient polling (`?updatedSince=`)

Already flagged in the SP-integration brainstorm under "Where this work lives".
Not required for correctness; worth it once many tasks sync.

- Add an `updatedAt timestamptz` column to `items` (`defaultNow()`, bumped on
  every mutating write — check/uncheck, assign, edit, restore). One migration +
  touching the existing item writes to set it.
- Accept `GET /api/integration/tasks?updatedSince=<ISO>` → `and(…, gt(items.updatedAt, since))`.
- Add `updatedAt` to the `SPTask` DTO so SP can store a high-water mark.

SP would then poll with its last-seen timestamp instead of refetching the full
assigned set each interval. Defer until the read volume justifies it.

---

## 7. Shared types to add (`packages/shared/src/types.ts`)

```ts
// response of POST /api/integration/tasks/:id/claim
export interface SPClaimTaskResponse {
  task: SPTask;
}

// response of GET /api/integration/claimable-tasks  (can reuse SPTasksResponse)
export type SPClaimableTasksResponse = SPTasksResponse;

// response of POST /api/integration/spaces
export interface SPCreateSpaceResponse {
  project: Pick<Project, 'id' | 'slug' | 'name' | 'purpose' | 'sharingMode'>;
  memberId: string;
}
```

And in `packages/shared/src/validation.ts`:

```ts
export const CreateSpaceViaTokenSchema = z.object({
  name: z.string().min(1).max(MAX_PROJECT_NAME_LENGTH),
  purpose: z.string().max(MAX_PURPOSE_LENGTH).default(''),
  displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
});
```

`SPTask` itself is unchanged (add `updatedAt` only if §6 is taken).

---

## 8. Tests (mirror `routes/integration.test.ts`)

`integration.test.ts` already has the harness (PAT minting + `app.request`).
Add, in the same style:

**claim**

- claims an unassigned task → `200`, row `assignedTo === myMember`, `item.assigned`
  activity row written, SSE `item.updated` emitted.
- claiming an already-assigned task → `409`, row unchanged.
- claiming a task in a project I'm **not** a member of → `404` (isolation).
- two concurrent claims → exactly one `200`, one `409` (atomic-update race).
- self-assignment does **not** insert an `assignmentNotifications` row.

**claimable-tasks**

- returns only `assignedTo IS NULL AND checked = false AND deletedAt IS NULL`
  within my projects; excludes mine/others'/done/deleted.
- `?projectId=` outside my scope returns `[]` (no foreign-project probe).

**create-space**

- `201`; `projects` + creator `members` + default `lists`/`scratchpads` rows
  exist; **the same PAT** can immediately `GET /tasks` scoped to the new project.
- validation failure → `422`; over-limit → `429`.

---

## 9. How SP consumes each (so the contract is mutually legible)

All isolated in SP's `PlainspaceApiService` (one file) — see
`docs/plainspace-integration-plan.md`:

| SP method                   | Endpoint                                     | Notes                                           |
| --------------------------- | -------------------------------------------- | ----------------------------------------------- |
| `getMe$` / `testConnection` | `GET /me`                                    | identity + space list                           |
| `getMyTasks$`               | `GET /tasks`                                 | client-filters `task.projectId === cfg.spaceId` |
| `getById$` / poll           | `GET /tasks/:id`                             | freshness for imported tasks                    |
| done write-back             | `PATCH /tasks/:id { done }`                  | on SP task complete/reopen                      |
| scheduled-time sync         | `PATCH /tasks/:id { scheduledAt }` + read    | `dueWithTime ↔ scheduledAt` (§4b)               |
| `getUnclaimedTasks$`        | `GET /claimable-tasks?projectId=cfg.spaceId` | claim pool feed                                 |
| `claimTask$`                | `POST /tasks/:id/claim`                      | then `addTaskFromIssue` imports it              |
| `createTask$`               | `POST /tasks { spaceId, title }`             | add task in a bound project → link `SPTask.id`  |
| `createSpace$`              | `POST /spaces`                               | bind provider `spaceId = project.id`            |

Two **client-side** fixes SP must make when going real (server unaffected, noting
for completeness): send `Authorization: Bearer <PAT>` on every call (the PAT
lives in `PlainspaceCfg`, not a mock account), and use `SPTask.url` directly for
"open in Plainspace" instead of constructing `…/spaces/:id/tasks/:id` (the real
link is `itemUrl` = `{origin}/{slug}/item/{id}`).

---

## 10. Out of scope (separate plans)

- **Device-code auth** (`/api/integration/device-code` + `/device-token`) — the
  real onboarding fix; see §5 and the SP brainstorm doc.
- **SP → Plainspace promotion** (assign an SP task to someone → seed a _new_
  Space + invite) — the dominant flow in the product vision, larger than this
  PR's needs. Note: adding a task to an _already-bound_ Space is now in scope —
  see §4c (`POST /api/integration/tasks`).
- **Assignee/“waiting-on” surfacing**, presence, comments, attachments.
- Per-occurrence reminders / repeat rules over the integration channel.

---

## 11. Summary — minimum to unblock SP PR #8424

1. `POST /api/integration/tasks/:taskId/claim` (§2) — **required** for the claim pool.
2. `GET /api/integration/claimable-tasks` (§3) — **required** for the claim pool.
3. `POST /api/integration/spaces` (§4) — **required** for "Share on Plainspace"
   (additional Spaces; first-Space onboarding waits on device-code, §5).
4. `scheduledAt`/`isRecurring` on `serializeSPTask` + `scheduledAt` on
   `PATCH /tasks/:id` (§4b) — **required** for scheduled-time sync. No new
   endpoint/table; extends the read DTO + PATCH via the in-app `applyRepeatUpdate`
   path (DTO `scheduledAt` ↔ db `remindAt`).
5. `?updatedSince=` + `items.updatedAt` (§6) — **optional**, polling efficiency.

All three required endpoints are ~1 handler each, reuse `apiTokenMiddleware` /
`loadIntegrationScope` / `serializeSPTask` / `recordActivity` / `sseManager`, and
add no new tables. Estimated surface: one new file or ~150 lines appended to
`routes/integration.ts`, a handful of shared-type lines, and the tests in §8.
