* docs(plainspace): add integration plan for shared projects
* feat(plainspace): prototype 'assigned to others' work-view panel
UI-only prototype of the dual-list shared-project view: a read-only
component that lists Plainspace tasks owned by other members, grouped by
assignee, rendered as a collapsible panel in the project work view.
Uses hard-coded sample data; foreign tasks never enter the SP task store
or op-log sync. See docs/plainspace-integration-plan.md.
* refactor(plainspace): apply review fixes to prototype panel
- use defined --text-color-muted / --s-half / --s2 tokens instead of an
undefined token and redundant spacing fallbacks
- drop unused PLAINSPACE.DONE i18n key
- note avatarUrl sanitization needed before live API data
- clarify plan doc: icon registration in GlobalThemeService, issueType
union widening, and which phases are design-only vs implemented
* feat(plainspace): add mock-backed issue provider + share-on-create toggle
Phase 1: register PLAINSPACE as an issue provider (config form, API
service with mock mode, IssueServiceInterface impl, issue.model/const/
service registration, icon, issue-content config). Tasks assigned to me or
unassigned import via the normal issue->backlog pipeline; tasks assigned to
others are excluded here (shown read-only by the separate panel).
Phase 3: add a 'Share on Plainspace' toggle to the create-project dialog
that provisions a (mock) space and a bound provider via
PlainspaceShareService.
All Plainspace calls are mocked (PLAINSPACE_USE_MOCK) so the flow works
without a live backend; the real HTTP contract is isolated in
PlainspaceApiService. Build + lint:ts + lint:scss pass; added a spec for
the provider's mock/filter logic (unit run blocked: no browser in env).
* refactor(plainspace): address review feedback on provider + share flow
- add a Plainspace tile to the issue-provider setup overview so the
provider is addable manually (connect to an existing space), not only
via the share-on-create toggle
- make PlainspaceShareService self-contained: catch errors, surface a
snack, never reject (safe to fire-and-forget); log ids only
- strip the transient isShareOnPlainspace flag before it can reach
sessionStorage on the dialog cancel path
- add a getIssueProviderTooltip case for PLAINSPACE (was falling through
to the raw key)
- drop a redundant cast now that PlainspaceIssue is in IssueDataReduced
* feat(plainspace): add account login / identity (Phase 2, mock)
- PlainspaceAccountService: signals (account/isLoggedIn/currentUserId),
mock login/logout, localStorage-persisted (local-only, never synced)
- 'mine' in the import filter now derives from the signed-in identity
instead of a hard-coded constant; not-logged-in => only unassigned
- the Share-on-Plainspace flow prompts (mock) sign-in if needed and
refuses to provision a space when the user declines
- move the mock user-id constant to the plainspace feature folder to
avoid a cross-folder import cycle
- specs for the account service and updated api-service spec (sign in
before asserting the mine/unassigned split)
Identity is mocked (fixed user id) so the assigned/unassigned split lines
up with the mock space data; real OAuth/token exchange is future work.
* feat(plainspace): feed 'assigned to others' panel from live mock data
Wire the work-view panel to a new PlainspaceSharedTasksService instead of
hard-coded sample data: for a project with a bound enabled PLAINSPACE
provider, it fetches the space's tasks, keeps only those assigned to
others (assigneeId !== me), maps them to read-only rows, and threads them
through project-task-page -> work-view as an input. The panel now appears
only for shared projects (not every project), and foreign tasks still
never enter the SP task store / op-log.
Removes the prototype data const; adds a service spec.
* refactor(plainspace): replace 'assigned to others' list with a claim pool
Reframe around task ownership: SP shows only tasks assigned to me (first-
class) plus a read-only 'claim pool' of unclaimed tasks. Tasks assigned to
others are no longer represented in SP.
- import filter is now assigned-to-me only (was mine+unassigned)
- new claim flow: PlainspaceApiService.claimTask$ (assign-to-self) +
PlainspaceClaimPoolService.claim -> IssueService.addTaskFromIssue, with
pool refresh; claimed task leaves the pool
- rename AssignedToOthers component/service -> claim-pool; panel is
collapsed by default (a pool you reach for, not active work)
- drop assignee badges (redundant: list is all 'mine'; provider icon
already marks Plainspace tasks)
- mock data made resettable for test isolation; specs updated/added
- docs: capture the ownership model + rationale for dropping the list
* docs(plainspace): fix remaining stale claim-pool reference
* feat(plainspace): connect provider to the real integration API
Replace the mock-backed PlainspaceApiService with the real PAT-authed
{host}/api/integration endpoints (me, tasks, claimable-tasks, claim,
spaces, done PATCH) and map the wire SPTask -> the internal PlainspaceIssue
so the rest of the provider depends on one stable shape.
- the server scopes /tasks to the caller, so drop client-side identity and
the mock data/identity consts; "mine" is decided server-side
- store the personal API token (PAT) in PlainspaceCfg like other providers'
secrets; keep a local-only account cache to bootstrap share-on-create
- add a token field to the config form; the share flow prompts for and
validates a PAT via /me before provisioning a space
- isEnabled now requires a token; testConnection hits /me; issueLink uses
the task's real url
Adds docs/plainspace-api-extension-plan.md documenting the server contract
this client consumes (the endpoints added to Johannesjo/spaces).
* feat(plainspace): guided connect dialog with token steps + link
Replace the bare one-line API-token prompt with PlainspaceConnectDialog,
which shows where to create a personal API token and validates it before
closing:
- numbered step-by-step (Space -> People -> Advanced -> API tokens) plus an
"Open Plainspace" link button
- inline validation via /me; an invalid token shows an error and keeps the
dialog open instead of failing silently
- wire it into the share-on-create flow and sharpen the config-form token
description to point at the same place
Adds the CONNECT i18n block (regenerates t.const) and drops the now-unused
LOGIN_PROMPT string; a new component spec covers connect/cancel + template.
* feat(plainspace): auto-import + two-way done sync
- default isAutoAddToBacklog to true so tasks assigned to me auto-import
into the bound project's backlog (poll-to-backlog), matching the seamless
intent
- add PlainspaceSyncAdapterService, registered for 'PLAINSPACE' in the
two-way-sync effect, so completing/reopening a task in SP pushes the done
state back to Plainspace via PATCH /tasks/:id { done }
Only isDone is pushed (the integration API's one writable field); title and
done changes from Plainspace are pulled by the existing update polling. Adds
an adapter spec and a Plainspace adapter spy in the two-way-sync effect spec.
* fix(plainspace): match bound space by slug or id so tasks import
getMyTasks$ filtered by `task.projectId === cfg.spaceId`, but the value users
copy from the space URL is the slug while SPTask.projectId is the UUID — so a
slug-configured provider matched zero tasks and imported nothing, even though
the /tasks response was full.
Match spaceId against both projectId and projectSlug (getMyTasks$ and the claim
pool), drop the server ?projectId= param (UUID-only) in favour of the same
client-side match, and point the Space ID help text at the slug in the URL.
* chore(plainspace): log import counts to diagnose missing imports
getMyTasks$ now logs total vs space-matched task counts (ids/counts only,
no task content) so we can tell whether the space filter or the import is
dropping tasks. Temporary diagnostic.
* feat: add picker for plainspace
* feat: improve wording
* feat(plainspace): use two-circle brand mark for provider icon
* refactor(plainspace): type create-project form to drop isShareOnPlainspace any-cast
* refactor(plainspace): remove temporary getMyTasks$ diagnostic log
* feat(plainspace): push scheduled time (dueWithTime → remindAt)
* fix(plainspace): seed two-way-sync baseline so write-back actually fires
* feat(plainspace): import remindAt as dueWithTime so schedule shows in app
* refactor(plainspace): align client to scheduledAt/isRecurring API contract
* feat(plainspace): flag recurring tasks in the claim pool
* feat(plainspace): pull scheduledAt into dueWithTime on poll (schedule existing + recurring tasks)
* feat(plainspace): push title changes back to Plainspace
* fix(plainspace): address multi-review findings (perf, UX, cleanup)
Performance:
- claim pool no longer re-fetches /claimable-tasks on every task
add/complete/reorder (distinct on project id + provider identity)
- poll imported tasks via one getMyTasks$ instead of N getById$ calls
UX:
- gate the "Share on Plainspace" toggle to project create (was a dead
control in edit mode)
- success/failure snacks on claim and share (silent 409/offline before)
- distinct error state in the space-picker (vs misleading "no spaces yet")
Correctness/cleanup:
- normalize scheduledAt to canonical UTC ISO on read so the push guard
can't silently drop a reschedule on a benign reformat
- drop dead PLAINSPACE_INITIAL_POLL_DELAY, PlainspaceMember, assignee
- translate the "Advanced Config" form label
* docs(plainspace): reconcile token storage + forward-compat rollout note
- document that the PAT lives in synced provider cfg like other providers
(the earlier "not stored here" note was never true in the shipped code)
- add the typia forward-compat rollout requirement for the new built-in
PLAINSPACE issue-provider key to the Risks section
- fix stale "assigned to others" wording (now the claim pool)
* feat(plainspace): trim redundant Notes button + issue panel
Plainspace mirrors title/done/schedule onto native task fields, so the
detail panel only echoed the task and the chat button opened an
essentially empty drawer.
- Treat PLAINSPACE like ICAL: don't show the *persistent* toggle button
just because the task carries an issueId. Keep it for real notes, a
remote update (issueWasUpdated) and the open-panel close state.
- Keep the panel reachable: the hover-only "open detail panel" button
(task-hover-controls) now covers PLAINSPACE too — it's the exact
complement of the persistent button, so plain/iCal/Plainspace tasks all
get a hover affordance and there's never a double button.
- Replace the redundant title-as-"Summary" link with the two things not
already on the task: a clear "Open in Plainspace" link and a recurrence
indicator rendered with the same `repeat` mat-icon the claim pool uses
(new 'plainspace-recurring' custom field).
- Add unit coverage for the new content config.
---------
Co-authored-by: Claude <noreply@anthropic.com>
23 KiB
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/integrationsurface 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
-
Load scope. If empty →
404 { error: 'Task not found' }. -
Look up the item by id, restricted to
scope.projectIds,isNull(deletedAt). Not found →404. -
Resolve the caller's member for that project:
member = scope.memberByProjectId.get(item.projectId). Missing →404. -
Atomic claim (handles the race where two members claim at once — same conditional-update pattern as the verification-code claim in
projects.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 -
recordActivity(tx, { action: 'item.assigned', targetType: 'item', targetId: taskId, memberId: member.id, meta: { text: item.text, assignedTo: member.id, source: 'sp' } }). -
After commit:
sseManager.broadcast(projectId, 'item.updated', { item: serializeItem(claimed), memberId: member.id })and theactivitybroadcast — exactly like the PATCH handler, so open web clients see the claim live. -
Do not enqueue an
assignmentNotificationsrow: claiming is a self-assignment, and that table already excludes self-assignments by design (see the comment onassignmentNotificationsand theassignee !== member.idguard initems.ts). Pinging yourself about a task you just claimed is noise. -
Return
{ task: serializeSPTask(claimed, list, proj, origin) }(fetchlistprojas 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 arbitraryassignedToover 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).claimis the one safe, self-scoped assignment: it can only ever setassignedTo = me, and only from unassigned. This matches the asymmetry indocs/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:
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 withscope.projectIdsso it can't be used to probe foreign projects. SP passes its boundspaceIdhere to avoid over-fetching unclaimed tasks from unrelated Spaces.- Visibility is membership-based and independent of
sharingMode: members of aprivateSpace 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(defaultscope=assigned). The dedicated path keeps/taskssemantics 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
- Decrypt the PAT's email:
decryptStoredEmail(row)is already done inapiTokenMiddleware; expose it viac.get('apiTokenEmail')(the middleware sets it). Normalize withnormalizeEmail. - Validate body with a new
CreateSpaceViaTokenSchema(zod, inpackages/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).safeParsefailure →422withdetails: error.flatten(). - Transaction (mirrors
projects.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 }); - 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
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/anchorinvariants the in-app PATCH uses (applyRepeatUpdateinitems.ts): clearingscheduledAt(→remindAt = null) cascades torepeat:null; re-scheduling a repeating item re-anchors the rule. SP never sends a rule, so ascheduledAt-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 settingscheduledAtis self-scoped scheduling, not assignment. - Validation: reject a
scheduledAtthat isn't a valid ISO instant ornull(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);isRecurringflags it. When the sweep advancesremindAt, SP's poll re-pulls the newscheduledAtand 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.
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 timestamptzcolumn toitems(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
updatedAtto theSPTaskDTO 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)
// 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:
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, rowassignedTo === myMember,item.assignedactivity row written, SSEitem.updatedemitted. - 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, one409(atomic-update race). - self-assignment does not insert an
assignmentNotificationsrow.
claimable-tasks
- returns only
assignedTo IS NULL AND checked = false AND deletedAt IS NULLwithin my projects; excludes mine/others'/done/deleted. ?projectId=outside my scope returns[](no foreign-project probe).
create-space
201;projects+ creatormembers+ defaultlists/scratchpadsrows exist; the same PAT can immediatelyGET /tasksscoped 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 |
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 Space + invite) — the dominant flow in the product vision, larger than this PR's needs.
- Assignee/“waiting-on” surfacing, presence, comments, attachments.
- Per-occurrence reminders / repeat rules over the integration channel.
11. Summary — minimum to unblock SP PR #8424
POST /api/integration/tasks/:taskId/claim(§2) — required for the claim pool.GET /api/integration/claimable-tasks(§3) — required for the claim pool.POST /api/integration/spaces(§4) — required for "Share on Plainspace" (additional Spaces; first-Space onboarding waits on device-code, §5).scheduledAt/isRecurringonserializeSPTask+scheduledAtonPATCH /tasks/:id(§4b) — required for scheduled-time sync. No new endpoint/table; extends the read DTO + PATCH via the in-appapplyRepeatUpdatepath (DTOscheduledAt↔ dbremindAt).?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.