deepspace 0.33.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +5 -1
  3. package/dist/build.js.map +1 -1
  4. package/dist/chunk-KELQ7OD5.js +1957 -0
  5. package/dist/chunk-KELQ7OD5.js.map +1 -0
  6. package/dist/{chunk-PZDVKSLI.js → chunk-SINBB5BQ.js} +1 -1
  7. package/dist/{chunk-PZDVKSLI.js.map → chunk-SINBB5BQ.js.map} +1 -1
  8. package/dist/cli.js +74 -15
  9. package/dist/cli.js.map +1 -1
  10. package/dist/documentation-client-core.js +1 -1
  11. package/dist/documentation-default-renderer.cjs +1 -1
  12. package/dist/documentation-react.js +1 -1
  13. package/dist/documentation-runtime.js +10 -10
  14. package/dist/documentation-server-core.js +1 -1
  15. package/dist/documentation.js +47 -22
  16. package/dist/documentation.js.map +1 -1
  17. package/dist/expo.d.ts +73 -0
  18. package/dist/expo.js +233 -0
  19. package/dist/expo.js.map +1 -0
  20. package/dist/index.d.ts +105 -22
  21. package/dist/index.js +50 -23
  22. package/dist/index.js.map +1 -1
  23. package/dist/server.d.ts +205 -37
  24. package/dist/server.js +290 -97
  25. package/dist/server.js.map +1 -1
  26. package/dist/worker.d.ts +312 -48
  27. package/dist/worker.js +499 -110
  28. package/dist/worker.js.map +1 -1
  29. package/features/documents/feature.json +15 -0
  30. package/features/documents/src/DocumentsListPage.tsx +22 -8
  31. package/features/documents/src/InviteDialog.tsx +63 -42
  32. package/features/documents/src/__tests__/documents-find-invitee-action.test.ts +131 -0
  33. package/features/documents/src/__tests__/documents-invitee-lookup.test.ts +67 -0
  34. package/features/documents/src/documents-find-invitee-action.ts +56 -0
  35. package/features/documents/src/documents-invitee-lookup.ts +38 -0
  36. package/features/documents/src/documents-ui.css +10 -0
  37. package/package.json +28 -8
@@ -56,6 +56,14 @@
56
56
  {
57
57
  "src": "src/editor/DocumentsTiptapToolbar.tsx",
58
58
  "dest": "src/pages/documents/editor/DocumentsTiptapToolbar.tsx"
59
+ },
60
+ {
61
+ "src": "src/documents-invitee-lookup.ts",
62
+ "dest": "src/pages/documents/documents-invitee-lookup.ts"
63
+ },
64
+ {
65
+ "src": "src/documents-find-invitee-action.ts",
66
+ "dest": "src/actions/documents-find-invitee.ts"
59
67
  }
60
68
  ],
61
69
  "schema": {
@@ -63,6 +71,13 @@
63
71
  "importPath": "./schemas/documents-schema",
64
72
  "spreadOperator": true
65
73
  },
74
+ "actions": [
75
+ {
76
+ "exportName": "documentsFindInviteeAction",
77
+ "registerAs": "documents-find-invitee",
78
+ "importPath": "./documents-find-invitee"
79
+ }
80
+ ],
66
81
  "route": {
67
82
  "path": "/documents",
68
83
  "protected": true
@@ -10,6 +10,7 @@ import { useUser } from 'deepspace'
10
10
  import { useQuery } from 'deepspace'
11
11
  import { useMutations } from 'deepspace'
12
12
  import { ROLES, type Role } from 'deepspace'
13
+ import { useToast } from '@/components/ui'
13
14
  import {
14
15
  FileText,
15
16
  Plus,
@@ -81,6 +82,7 @@ function formatDate(iso: string): string {
81
82
  export default function DocumentsListPage() {
82
83
  const { user } = useUser()
83
84
  const navigate = useNavigate()
85
+ const toast = useToast()
84
86
  const userRole = (user?.role ?? ROLES.VIEWER) as Role
85
87
  const roleCanCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
86
88
 
@@ -233,15 +235,27 @@ export default function DocumentsListPage() {
233
235
 
234
236
  const handleNewDocument = useCallback(async () => {
235
237
  if (!user || !canCreate) return
236
- const id = await createConfirmed({
237
- title: UNTITLED,
238
- ownerId: user.id,
239
- collaborators: '[]',
240
- editors: '[]',
241
- folderId: folderIdForNew,
242
- })
238
+ // A confirmed write answers with an ack the provider-level onWriteError
239
+ // never sees, so a refusal or timeout has to be reported here — otherwise
240
+ // the click does nothing and the rejection is unhandled.
241
+ let id: string
242
+ try {
243
+ id = await createConfirmed({
244
+ title: UNTITLED,
245
+ ownerId: user.id,
246
+ collaborators: '[]',
247
+ editors: '[]',
248
+ folderId: folderIdForNew,
249
+ })
250
+ } catch (err) {
251
+ toast.error(
252
+ 'Document not created',
253
+ err instanceof Error ? err.message : 'DeepSpace did not confirm the write.',
254
+ )
255
+ return
256
+ }
243
257
  navigate(`/documents/${id}`)
244
- }, [canCreate, createConfirmed, folderIdForNew, navigate, user])
258
+ }, [canCreate, createConfirmed, folderIdForNew, navigate, toast, user])
245
259
 
246
260
  const handleDelete = useCallback(
247
261
  async (docId: string) => {
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import type { RecordData } from 'deepspace'
3
- import { useMutations, useQuery, useUser } from 'deepspace'
3
+ import { useMutations, useUser, useUserLookup } from 'deepspace'
4
4
  import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
5
5
  import { Modal, useToast } from '@/components/ui'
6
6
  import {
@@ -8,10 +8,15 @@ import {
8
8
  type DocumentsDocumentFields,
9
9
  type InviteAclDiff,
10
10
  } from './documents-library-types'
11
+ import { findDocumentsInvitee } from './documents-invitee-lookup'
12
+ import type { DocumentsInviteeLookup } from '@/actions/documents-find-invitee'
11
13
 
12
- interface UserFields {
14
+ /** One row of "People with access", drawn from the roster every member receives. */
15
+ interface PersonRow {
16
+ id: string
17
+ name: string
18
+ /** Only admins receive emails in the roster. */
13
19
  email?: string
14
- name?: string
15
20
  imageUrl?: string
16
21
  }
17
22
 
@@ -46,14 +51,18 @@ function initialsFor(name: string): string {
46
51
 
47
52
  export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
48
53
  const { user } = useUser()
49
- const { records: users, status: usersStatus } = useQuery<UserFields>('users')
54
+ // The roster names everyone who has signed in to the app, projected to
55
+ // public identity for members. A plain `useQuery('users')` is self-only
56
+ // under the scaffold's `member: { read: 'own' }` policy, so it cannot name
57
+ // a collaborator — or resolve an email — for an ordinary owner.
58
+ const { getUser } = useUserLookup()
50
59
  const { putConfirmed, ready: documentsMutationsReady } =
51
60
  useMutations<DocumentsDocumentFields>('documents')
52
61
  const toast = useToast()
53
62
  const [email, setEmail] = useState('')
54
63
  const [role, setRole] = useState<InviteRole>('editor')
55
64
  const [saving, setSaving] = useState(false)
56
- const ready = usersStatus === 'ready' && documentsMutationsReady
65
+ const ready = documentsMutationsReady
57
66
 
58
67
  const collaborators = useMemo(
59
68
  () => parseDocumentsIdList(doc.data.collaborators),
@@ -61,17 +70,22 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
61
70
  )
62
71
  const editors = useMemo(() => parseDocumentsIdList(doc.data.editors), [doc.data.editors])
63
72
 
64
- const ownerRecord = useMemo(
65
- () => users.find((u) => u.recordId === doc.data.ownerId),
66
- [doc.data.ownerId, users],
67
- )
73
+ const ownerRecord = useMemo(() => getUser(doc.data.ownerId), [doc.data.ownerId, getUser])
68
74
 
69
- const collaboratorRecords = useMemo(
75
+ // Every collaborator id stays listed even before the roster names it, so
76
+ // access can always be changed or removed.
77
+ const collaboratorRecords = useMemo<PersonRow[]>(
70
78
  () =>
71
- collaborators
72
- .map((id) => users.find((u) => u.recordId === id))
73
- .filter((u): u is RecordData<UserFields> => Boolean(u)),
74
- [collaborators, users],
79
+ collaborators.map((id) => {
80
+ const u = getUser(id)
81
+ return {
82
+ id,
83
+ name: u?.name?.trim() || 'Collaborator',
84
+ email: u?.email,
85
+ imageUrl: u?.imageUrl,
86
+ }
87
+ }),
88
+ [collaborators, getUser],
75
89
  )
76
90
 
77
91
  if (!isOwner) return null
@@ -122,30 +136,41 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
122
136
 
123
137
  const addInvite = async () => {
124
138
  const normalized = email.trim().toLowerCase()
125
- if (!normalized) return
139
+ if (!normalized || !ready) return
126
140
 
127
- const target = users.find((u) => u.data.email?.trim().toLowerCase() === normalized)
128
- if (!target) {
141
+ // The email resolves server-side (owner-only action): members cannot read
142
+ // other users' rows, and the roster carries no emails for them.
143
+ let target: DocumentsInviteeLookup
144
+ setSaving(true)
145
+ try {
146
+ target = await findDocumentsInvitee(doc.recordId, normalized)
147
+ } catch (err) {
148
+ toast.error('Could not look up that email', err instanceof Error ? err.message : undefined)
149
+ return
150
+ } finally {
151
+ setSaving(false)
152
+ }
153
+ if (!target.found) {
129
154
  toast.error(
130
155
  'User not found',
131
156
  'Ask them to sign in to this app once, then invite the same email again.',
132
157
  )
133
158
  return
134
159
  }
135
- if (target.recordId === doc.data.ownerId || target.recordId === user?.id) {
160
+ if (target.userId === doc.data.ownerId || target.userId === user?.id) {
136
161
  toast.info('Already has access', 'That user is the document owner.')
137
162
  return
138
163
  }
139
- if (collaborators.includes(target.recordId)) {
140
- toast.info('Already invited', `${target.data.email ?? normalized} already has access.`)
164
+ if (collaborators.includes(target.userId)) {
165
+ toast.info('Already invited', `${normalized} already has access.`)
141
166
  return
142
167
  }
143
168
 
144
- const nextCollaborators = [...collaborators, target.recordId]
145
- const nextEditors = role === 'editor' ? [...editors, target.recordId] : editors
169
+ const nextCollaborators = [...collaborators, target.userId]
170
+ const nextEditors = role === 'editor' ? [...editors, target.userId] : editors
146
171
  if (!(await saveAccess(nextCollaborators, nextEditors))) return
147
172
  setEmail('')
148
- toast.success('Invite added', `${target.data.email ?? normalized} now has ${role} access.`)
173
+ toast.success('Invite added', `${normalized} now has ${role} access.`)
149
174
  }
150
175
 
151
176
  const setCollaboratorRole = async (userId: string, nextRole: InviteRole) => {
@@ -163,12 +188,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
163
188
  )
164
189
  }
165
190
 
166
- const ownerName =
167
- ownerRecord?.data.name?.trim() ||
168
- ownerRecord?.data.email?.trim() ||
169
- user?.name ||
170
- user?.email ||
171
- 'Owner'
191
+ const ownerName = ownerRecord?.name?.trim() || user?.name || user?.email || 'Owner'
172
192
 
173
193
  return (
174
194
  <Modal
@@ -216,6 +236,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
216
236
  <select
217
237
  value={role}
218
238
  onChange={(e) => setRole(e.target.value as InviteRole)}
239
+ // addInvite grants the role captured when Add was pressed; lock
240
+ // the select while the lookup and save run so the UI cannot show
241
+ // one role and grant another.
242
+ disabled={!ready || saving}
219
243
  className="h-10 rounded-lg border bg-transparent px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
220
244
  style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
221
245
  >
@@ -251,7 +275,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
251
275
  <div className="min-w-0 flex-1">
252
276
  <div className="truncate text-sm font-medium">{ownerName}</div>
253
277
  <div className="truncate text-xs" style={{ color: 'var(--documents-el-muted)' }}>
254
- {ownerRecord?.data.email ?? user?.email ?? 'Owner'}
278
+ {ownerRecord?.email ?? user?.email ?? 'Owner'}
255
279
  </div>
256
280
  </div>
257
281
  <span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium">
@@ -261,39 +285,36 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
261
285
  </div>
262
286
 
263
287
  {collaboratorRecords.map((u) => {
264
- const name = u.data.name?.trim() || u.data.email?.trim() || 'Collaborator'
265
- const userRole: InviteRole = editors.includes(u.recordId) ? 'editor' : 'viewer'
288
+ const userRole: InviteRole = editors.includes(u.id) ? 'editor' : 'viewer'
266
289
  return (
267
290
  <div
268
- key={u.recordId}
291
+ key={u.id}
269
292
  className="flex items-center gap-3 rounded-xl border p-3"
270
293
  style={{ borderColor: 'var(--documents-el-line)' }}
271
294
  >
272
- {u.data.imageUrl ? (
295
+ {u.imageUrl ? (
273
296
  <img
274
- src={u.data.imageUrl}
297
+ src={u.imageUrl}
275
298
  alt=""
276
299
  className="h-9 w-9 shrink-0 rounded-full object-cover"
277
300
  />
278
301
  ) : (
279
302
  <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-black/5 text-xs font-bold dark:bg-white/10">
280
- {initialsFor(name)}
303
+ {initialsFor(u.name)}
281
304
  </div>
282
305
  )}
283
306
  <div className="min-w-0 flex-1">
284
- <div className="truncate text-sm font-medium">{name}</div>
307
+ <div className="truncate text-sm font-medium">{u.name}</div>
285
308
  <div
286
309
  className="truncate text-xs"
287
310
  style={{ color: 'var(--documents-el-muted)' }}
288
311
  >
289
- {u.data.email ?? 'No email'}
312
+ {u.email ?? (userRole === 'editor' ? 'Can edit' : 'Can view')}
290
313
  </div>
291
314
  </div>
292
315
  <select
293
316
  value={userRole}
294
- onChange={(e) =>
295
- void setCollaboratorRole(u.recordId, e.target.value as InviteRole)
296
- }
317
+ onChange={(e) => void setCollaboratorRole(u.id, e.target.value as InviteRole)}
297
318
  disabled={!ready || saving}
298
319
  className="h-8 rounded-lg border bg-transparent px-2 text-xs font-medium outline-none"
299
320
  style={{
@@ -306,7 +327,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
306
327
  </select>
307
328
  <button
308
329
  type="button"
309
- onClick={() => void removeCollaborator(u.recordId)}
330
+ onClick={() => void removeCollaborator(u.id)}
310
331
  disabled={!ready || saving}
311
332
  className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-500/10 disabled:opacity-50"
312
333
  title="Remove access"
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ActionContext, ActionTools } from 'deepspace/worker'
3
+ import { documentsFindInviteeAction } from '../documents-find-invitee-action'
4
+
5
+ type Doc = { ownerId: string }
6
+ type UserRow = { email: string; name: string }
7
+
8
+ /**
9
+ * The action's tools run RBAC-off, so the fake answers whatever the handler
10
+ * asks for; the assertions are about what the handler asks for and returns.
11
+ */
12
+ function fakeTools(opts: {
13
+ document?: Doc | null
14
+ users?: Array<{ recordId: string; data: UserRow }>
15
+ usersQueryFails?: boolean
16
+ }): ActionTools & { query: ReturnType<typeof vi.fn> } {
17
+ const get = vi.fn(async () =>
18
+ opts.document
19
+ ? {
20
+ success: true as const,
21
+ data: {
22
+ record: {
23
+ recordId: 'doc-1',
24
+ data: opts.document,
25
+ createdBy: opts.document.ownerId,
26
+ createdAt: 't',
27
+ updatedAt: 't',
28
+ },
29
+ },
30
+ }
31
+ : { success: false as const, error: 'Record not found' },
32
+ )
33
+ const query = vi.fn(async () =>
34
+ opts.usersQueryFails
35
+ ? { success: false as const, error: 'boom' }
36
+ : {
37
+ success: true as const,
38
+ data: {
39
+ records: (opts.users ?? []).map((u) => ({
40
+ ...u,
41
+ createdBy: u.recordId,
42
+ createdAt: 't',
43
+ updatedAt: 't',
44
+ })),
45
+ count: (opts.users ?? []).length,
46
+ },
47
+ },
48
+ )
49
+ return { get, query } as unknown as ActionTools & { query: ReturnType<typeof vi.fn> }
50
+ }
51
+
52
+ function ctx(userId: string, params: Record<string, unknown>, tools: ActionTools): ActionContext {
53
+ return { userId, params, tools, env: {}, callerJwt: 'jwt' }
54
+ }
55
+
56
+ describe('documentsFindInviteeAction', () => {
57
+ it('refuses a call without a document id or an email', async () => {
58
+ const tools = fakeTools({ document: { ownerId: 'owner' } })
59
+ expect(await documentsFindInviteeAction(ctx('owner', { email: 'b@x.test' }, tools))).toEqual({
60
+ success: false,
61
+ error: 'docId and email are required',
62
+ })
63
+ expect(await documentsFindInviteeAction(ctx('owner', { docId: 'doc-1' }, tools))).toEqual({
64
+ success: false,
65
+ error: 'docId and email are required',
66
+ })
67
+ expect(tools.query).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it('refuses an unknown document with the same answer as a foreign one (no existence oracle)', async () => {
71
+ const tools = fakeTools({ document: null })
72
+ const result = await documentsFindInviteeAction(
73
+ ctx('owner', { docId: 'missing', email: 'b@x.test' }, tools),
74
+ )
75
+ expect(result).toEqual({ success: false, error: 'Only the document owner can share it' })
76
+ expect(tools.query).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('refuses a caller who is not the document owner without touching the users table', async () => {
80
+ const tools = fakeTools({
81
+ document: { ownerId: 'owner' },
82
+ users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
83
+ })
84
+ const result = await documentsFindInviteeAction(
85
+ ctx('editor', { docId: 'doc-1', email: 'b@x.test' }, tools),
86
+ )
87
+ expect(result).toEqual({ success: false, error: 'Only the document owner can share it' })
88
+ expect(tools.query).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('resolves an exact email to the public user id for the document owner', async () => {
92
+ const tools = fakeTools({
93
+ document: { ownerId: 'owner' },
94
+ users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
95
+ })
96
+ const result = await documentsFindInviteeAction(
97
+ ctx('owner', { docId: 'doc-1', email: 'b@x.test' }, tools),
98
+ )
99
+ // Exactly the id: no email, name, or other column leaves the action.
100
+ expect(result).toEqual({ success: true, data: { found: true, userId: 'u-b' } })
101
+ expect(tools.query).toHaveBeenCalledTimes(1)
102
+ expect(tools.query).toHaveBeenCalledWith('users', { where: { email: 'b@x.test' }, limit: 1 })
103
+ })
104
+
105
+ it('normalizes the email the way the auth plane stores it before matching', async () => {
106
+ const tools = fakeTools({
107
+ document: { ownerId: 'owner' },
108
+ users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
109
+ })
110
+ await documentsFindInviteeAction(
111
+ ctx('owner', { docId: 'doc-1', email: ' B@X.Test \n' }, tools),
112
+ )
113
+ expect(tools.query).toHaveBeenCalledWith('users', { where: { email: 'b@x.test' }, limit: 1 })
114
+ })
115
+
116
+ it('reports found: false when nobody with that email has signed in', async () => {
117
+ const tools = fakeTools({ document: { ownerId: 'owner' }, users: [] })
118
+ const result = await documentsFindInviteeAction(
119
+ ctx('owner', { docId: 'doc-1', email: 'nobody@x.test' }, tools),
120
+ )
121
+ expect(result).toEqual({ success: true, data: { found: false } })
122
+ })
123
+
124
+ it('surfaces a failed users query instead of reporting not found', async () => {
125
+ const tools = fakeTools({ document: { ownerId: 'owner' }, usersQueryFails: true })
126
+ const result = await documentsFindInviteeAction(
127
+ ctx('owner', { docId: 'doc-1', email: 'b@x.test' }, tools),
128
+ )
129
+ expect(result).toEqual({ success: false, error: 'User lookup failed' })
130
+ })
131
+ })
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // The lookup signs its request with the app session's bearer token; the
4
+ // dialog imports it from the public `deepspace` entry, so mock that boundary.
5
+ vi.mock('deepspace', () => ({ getAuthToken: async () => 'jwt-123' }))
6
+
7
+ import { findDocumentsInvitee } from '../documents-invitee-lookup'
8
+
9
+ function jsonResponse(status: number, body: unknown): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ })
14
+ }
15
+
16
+ describe('findDocumentsInvitee', () => {
17
+ const fetchMock = vi.fn<typeof fetch>()
18
+
19
+ beforeEach(() => {
20
+ fetchMock.mockReset()
21
+ vi.stubGlobal('fetch', fetchMock)
22
+ })
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals()
25
+ })
26
+
27
+ it('posts the document id and email to the feature action with the session bearer', async () => {
28
+ fetchMock.mockResolvedValue(
29
+ jsonResponse(200, { success: true, data: { found: true, userId: 'u-b' } }),
30
+ )
31
+
32
+ const result = await findDocumentsInvitee('doc-1', 'b@x.test')
33
+
34
+ expect(result).toEqual({ found: true, userId: 'u-b' })
35
+ expect(fetchMock).toHaveBeenCalledTimes(1)
36
+ const [url, init] = fetchMock.mock.calls[0]
37
+ expect(url).toBe('/api/actions/documents-find-invitee')
38
+ expect(init?.method).toBe('POST')
39
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer jwt-123')
40
+ expect(new Headers(init?.headers).get('Content-Type')).toBe('application/json')
41
+ expect(JSON.parse(String(init?.body))).toEqual({ docId: 'doc-1', email: 'b@x.test' })
42
+ })
43
+
44
+ it('passes a not-found answer through unchanged', async () => {
45
+ fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { found: false } }))
46
+ expect(await findDocumentsInvitee('doc-1', 'nobody@x.test')).toEqual({ found: false })
47
+ })
48
+
49
+ it("throws the action's own refusal text so the dialog can show it", async () => {
50
+ fetchMock.mockResolvedValue(
51
+ jsonResponse(200, { success: false, error: 'Only the document owner can share it' }),
52
+ )
53
+ await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow(
54
+ 'Only the document owner can share it',
55
+ )
56
+ })
57
+
58
+ it('throws a status-bearing error when the route itself fails', async () => {
59
+ fetchMock.mockResolvedValue(new Response('<html>', { status: 502 }))
60
+ await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow('Lookup failed (502)')
61
+ })
62
+
63
+ it('throws when the action route is missing (feature installed without its action)', async () => {
64
+ fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Action not found' }))
65
+ await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow('Action not found')
66
+ })
67
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Documents feature — owner-only invitee lookup.
3
+ *
4
+ * The Share dialog resolves an email to a user id. The client cannot do that
5
+ * itself: the scaffold's users schema is `member: { read: 'own' }`, so an
6
+ * ordinary member's `useQuery('users')` holds only their own row, and the
7
+ * `useUsers()` roster is projected to public identity (no email). Only the
8
+ * app owner (admin) reads full rows.
9
+ *
10
+ * Tools run RBAC-off, so this action is the authorization boundary: only the
11
+ * owner of `docId` may ask, only by exact email, and the answer is the public
12
+ * id alone — the same id every signed-in member already sees in the roster.
13
+ *
14
+ * What it deliberately adds: any member who owns a document (members can
15
+ * create one) can learn whether an exact address has signed in to this app.
16
+ * That is the price of invite-by-email; there is no list surface and no rate
17
+ * limit here. Being RBAC-off, the query also ignores a users schema's
18
+ * `roster: 'read-policy'` partitioning — an app that must not resolve
19
+ * addresses across tenants should remove or adapt this action.
20
+ */
21
+
22
+ import type { ActionHandler } from 'deepspace/worker'
23
+
24
+ export type DocumentsInviteeLookup = { found: false } | { found: true; userId: string }
25
+
26
+ type DocumentRow = { ownerId?: string }
27
+ type UserRow = { email?: string }
28
+
29
+ // `<unknown>`: the lookup never reads `env`, so it registers under any app's
30
+ // `Record<string, ActionHandler<Env>>` without naming that app's bindings.
31
+ export const documentsFindInviteeAction: ActionHandler<unknown> = async ({
32
+ userId,
33
+ params,
34
+ tools,
35
+ }) => {
36
+ const docId = typeof params.docId === 'string' ? params.docId : ''
37
+ // The room stores emails lowercased (registerUser), so exact equality on the
38
+ // normalized input is the whole match.
39
+ const email = typeof params.email === 'string' ? params.email.trim().toLowerCase() : ''
40
+ if (!docId || !email) return { success: false, error: 'docId and email are required' }
41
+
42
+ // One refusal for "no such document" and "not yours": a distinct not-found
43
+ // answer would let any member probe which document ids exist.
44
+ const doc = await tools.get<DocumentRow>('documents', docId)
45
+ if (!doc.success || doc.data.record.data.ownerId !== userId) {
46
+ return { success: false, error: 'Only the document owner can share it' }
47
+ }
48
+
49
+ const users = await tools.query<UserRow>('users', { where: { email }, limit: 1 })
50
+ if (!users.success) return { success: false, error: 'User lookup failed' }
51
+ const match = users.data.records[0]
52
+ const data: DocumentsInviteeLookup = match
53
+ ? { found: true, userId: match.recordId }
54
+ : { found: false }
55
+ return { success: true, data }
56
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Client half of the Share dialog's invitee lookup: calls the feature's
3
+ * owner-only server action (`src/actions/documents-find-invitee.ts`) with the
4
+ * app session's bearer token and hands back its answer.
5
+ *
6
+ * Kept apart from the dialog so the wire contract is testable without
7
+ * rendering the modal.
8
+ */
9
+
10
+ import { getAuthToken } from 'deepspace'
11
+ import type { ActionResult } from 'deepspace/worker'
12
+ import type { DocumentsInviteeLookup } from '@/actions/documents-find-invitee'
13
+
14
+ const ACTION_PATH = '/api/actions/documents-find-invitee'
15
+
16
+ /** The action's own result envelope, or the route's `{ error }` for 401/404. */
17
+ type ActionEnvelope = ActionResult<DocumentsInviteeLookup> | { error?: string }
18
+
19
+ /** Throws with the action's own refusal text, or a status-bearing message. */
20
+ export async function findDocumentsInvitee(
21
+ docId: string,
22
+ email: string,
23
+ ): Promise<DocumentsInviteeLookup> {
24
+ const token = await getAuthToken()
25
+ const res = await fetch(ACTION_PATH, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
30
+ },
31
+ body: JSON.stringify({ docId, email }),
32
+ })
33
+ const body = (await res.json().catch(() => null)) as ActionEnvelope | null
34
+ if (!res.ok || !body || !('success' in body) || !body.success) {
35
+ throw new Error((body && 'error' in body && body.error) || `Lookup failed (${res.status})`)
36
+ }
37
+ return body.data
38
+ }
@@ -178,12 +178,22 @@
178
178
  color: var(--documents-editor-paper-muted);
179
179
  }
180
180
 
181
+ /* Tailwind's preflight resets `ul, ol { list-style: none }`; the editor's
182
+ lists need their markers back. The task-list rule below opts back out. */
181
183
  .documents-paged-editor .ProseMirror ul,
182
184
  .documents-paged-editor .ProseMirror ol {
183
185
  padding-left: 1.4em;
184
186
  margin: 0 0 0.45em;
185
187
  }
186
188
 
189
+ .documents-paged-editor .ProseMirror ul {
190
+ list-style-type: disc;
191
+ }
192
+
193
+ .documents-paged-editor .ProseMirror ol {
194
+ list-style-type: decimal;
195
+ }
196
+
187
197
  .documents-paged-editor .ProseMirror ul[data-type='taskList'] {
188
198
  list-style: none;
189
199
  padding: 0;