@cedarjs/cli 8.0.0-canary.3548 → 8.0.0-canary.3550

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.
@@ -7,19 +7,21 @@ const ORGANIZATION_MODEL = `model Organization {
7
7
  memberships Membership[]
8
8
  }`;
9
9
  const MEMBERSHIP_MODEL = `model Membership {
10
- id String @id @default(cuid())
11
- role String
10
+ id String @id @default(cuid())
11
+ role String
12
12
  // Null while the membership is a pending invitation.
13
- userId String?
14
- organizationId String
15
- invitedById String?
16
- invitationToken String? @unique
17
- createdAt DateTime @default(now())
18
- updatedAt DateTime @updatedAt
19
- user User? @relation(fields: [userId], references: [id])
20
- organization Organization @relation(fields: [organizationId], references: [id])
21
- invitedBy Membership? @relation("Inviter", fields: [invitedById], references: [id])
22
- invitees Membership[] @relation("Inviter")
13
+ userId String?
14
+ organizationId String
15
+ invitedById String?
16
+ invitationToken String? @unique
17
+ // When a pending invitation stops being claimable.
18
+ invitationExpiresAt DateTime?
19
+ createdAt DateTime @default(now())
20
+ updatedAt DateTime @updatedAt
21
+ user User? @relation(fields: [userId], references: [id])
22
+ organization Organization @relation(fields: [organizationId], references: [id])
23
+ invitedBy Membership? @relation("Inviter", fields: [invitedById], references: [id])
24
+ invitees Membership[] @relation("Inviter")
23
25
 
24
26
  @@unique([userId, organizationId])
25
27
  @@index([organizationId])
@@ -48,6 +48,16 @@ export const standard = defineScenario<
48
48
  data: {
49
49
  role: 'admin',
50
50
  invitationToken: 'invite-token',
51
+ invitationExpiresAt: new Date('2999-01-01T00:00:00Z'),
52
+ organization: { connect: { slug: 'acme-inc' } },
53
+ },
54
+ },
55
+ // A pending invitation whose expiry has passed.
56
+ expired: {
57
+ data: {
58
+ role: 'member',
59
+ invitationToken: 'expired-token',
60
+ invitationExpiresAt: new Date('2000-01-01T00:00:00Z'),
51
61
  organization: { connect: { slug: 'acme-inc' } },
52
62
  },
53
63
  },
@@ -55,4 +65,8 @@ export const standard = defineScenario<
55
65
  })
56
66
 
57
67
  export type StandardScenario = ScenarioData<Organization, 'organization', 'acme'> &
58
- ScenarioData<Membership, 'membership', 'owner' | 'admin' | 'pending'>
68
+ ScenarioData<
69
+ Membership,
70
+ 'membership',
71
+ 'owner' | 'admin' | 'pending' | 'expired'
72
+ >
@@ -30,6 +30,8 @@ export const schema = gql`
30
30
  type Invitation {
31
31
  membership: Membership!
32
32
  invitationToken: String!
33
+ "When the invitation stops being claimable."
34
+ invitationExpiresAt: DateTime!
33
35
  }
34
36
 
35
37
  input CreateOrganizationInput {
@@ -61,6 +63,7 @@ export const schema = gql`
61
63
  organizations.ts) -- an admin can invite an admin or a member, but not an
62
64
  owner. The app is responsible for emailing input.email a link built from
63
65
  the returned invitationToken; Membership has no email column of its own.
66
+ The invitation can be claimed until the returned invitationExpiresAt.
64
67
  Web callers must call reauthenticate() from useAuth() in onCompleted so
65
68
  the invite shows up if the inviter is also the invitee.
66
69
  """
@@ -70,7 +73,7 @@ export const schema = gql`
70
73
  """
71
74
  Attaches the pending membership matching token to the signed-in user, or
72
75
  is a no-op if it is already attached to them. Throws if the token is
73
- invalid or was already claimed by someone else.
76
+ invalid, expired or was already claimed by someone else.
74
77
  Web callers must call reauthenticate() from useAuth() in onCompleted so
75
78
  the new membership is reflected in currentUser.memberships.
76
79
  """
@@ -99,6 +99,27 @@ describe('organizations service', () => {
99
99
  },
100
100
  )
101
101
 
102
+ scenario(
103
+ 'sets an expiry in the future',
104
+ async (scenario: StandardScenario) => {
105
+ asMember(scenario, scenario.membership.owner)
106
+
107
+ const result = await inviteMember({
108
+ input: { email: 'new-member@example.com', role: 'member' },
109
+ })
110
+
111
+ const created = await db.membership.findFirst({
112
+ where: { invitationToken: result.invitationToken },
113
+ })
114
+
115
+ expect(created?.invitationExpiresAt).toBeInstanceOf(Date)
116
+ expect(created!.invitationExpiresAt!.getTime()).toBeGreaterThan(
117
+ Date.now(),
118
+ )
119
+ expect(result.invitationExpiresAt).toEqual(created?.invitationExpiresAt)
120
+ },
121
+ )
122
+
102
123
  scenario(
103
124
  'an admin cannot invite an owner',
104
125
  async (scenario: StandardScenario) => {
@@ -316,6 +337,34 @@ describe('organizations service', () => {
316
337
  },
317
338
  )
318
339
 
340
+ scenario(
341
+ 'rejects an expired invitation and leaves it unclaimed',
342
+ async (scenario: StandardScenario) => {
343
+ const invitee = await db.user.create({
344
+ data: {
345
+ email: 'invitee@example.com',
346
+ hashedPassword: 'x',
347
+ salt: 'x',
348
+ },
349
+ })
350
+
351
+ const token = scenario.membership.expired.invitationToken!
352
+
353
+ mockCurrentUser({ id: invitee.id })
354
+
355
+ await expect(acceptInvitation({ token })).rejects.toThrow(
356
+ /has expired/,
357
+ )
358
+
359
+ const row = await db.membership.findUnique({
360
+ where: { id: scenario.membership.expired.id },
361
+ })
362
+
363
+ expect(row?.userId).toBeNull()
364
+ expect(row?.invitationToken).toEqual(token)
365
+ },
366
+ )
367
+
319
368
  it('rejects a token that never matched a pending invitation', async () => {
320
369
  mockCurrentUser({ id: 'some-user-id' })
321
370
 
@@ -11,6 +11,12 @@ import { requireCurrentOrg } from '@cedarjs/tenancy'
11
11
 
12
12
  import { Prisma, db } from 'src/lib/db'
13
13
 
14
+ /**
15
+ * How long an invitation stays claimable after `inviteMember` creates it.
16
+ * Change it if seven days does not suit the app.
17
+ */
18
+ const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000
19
+
14
20
  interface MembershipSummary {
15
21
  id: string
16
22
  organizationId: string
@@ -143,7 +149,9 @@ export const createOrganization: MutationResolvers['createOrganization'] =
143
149
  * `Membership` has no email column: the caller is responsible for emailing
144
150
  * `input.email` a link built from the returned `invitationToken`, with its
145
151
  * own mailer. The token is returned here and nowhere else -- see the SDL
146
- * doc comment on `Invitation` for why.
152
+ * doc comment on `Invitation` for why. The invitation can be claimed until
153
+ * `invitationExpiresAt`, which is returned with the token so the email can
154
+ * say when the link stops working.
147
155
  */
148
156
  export const inviteMember: MutationResolvers['inviteMember'] = async ({
149
157
  input,
@@ -164,6 +172,7 @@ export const inviteMember: MutationResolvers['inviteMember'] = async ({
164
172
  }
165
173
 
166
174
  const invitationToken = crypto.randomUUID()
175
+ const invitationExpiresAt = new Date(Date.now() + INVITATION_LIFETIME_MS)
167
176
 
168
177
  const membership = await db.membership.create({
169
178
  data: {
@@ -171,10 +180,11 @@ export const inviteMember: MutationResolvers['inviteMember'] = async ({
171
180
  organizationId: currentOrg.id,
172
181
  invitedById: currentOrg.membershipId,
173
182
  invitationToken,
183
+ invitationExpiresAt,
174
184
  },
175
185
  })
176
186
 
177
- return { membership, invitationToken }
187
+ return { membership, invitationToken, invitationExpiresAt }
178
188
  }
179
189
 
180
190
  /**
@@ -203,16 +213,23 @@ interface ClaimedInvitation {
203
213
  * `updateManyAndReturn` because Prisma only supports the latter on
204
214
  * PostgreSQL, CockroachDB and SQLite.
205
215
  *
216
+ * The same guard refuses an expired invitation: it only matches while
217
+ * `invitationExpiresAt` is in the future.
218
+ *
206
219
  * Returns the claimed membership's organization id when `userId` holds the
207
220
  * membership, whether this call claimed it or an earlier one did, or `null`
208
- * when the token is invalid or was claimed by a different user.
221
+ * when the token is invalid, expired, or was claimed by a different user.
209
222
  */
210
223
  const claimInvitation = async (
211
224
  token: string,
212
225
  userId: string,
213
226
  ): Promise<ClaimedInvitation | null> => {
214
227
  await db.membership.updateMany({
215
- where: { invitationToken: token, userId: null },
228
+ where: {
229
+ invitationToken: token,
230
+ userId: null,
231
+ invitationExpiresAt: { gt: new Date() },
232
+ },
216
233
  data: { userId, invitationToken: spentInvitationToken(token) },
217
234
  })
218
235
 
@@ -223,11 +240,29 @@ const claimInvitation = async (
223
240
  return claimed ? { organizationId: claimed.organizationId } : null
224
241
  }
225
242
 
243
+ /**
244
+ * Whether `token` belongs to an invitation that is still unclaimed but past
245
+ * its expiry. Only used to pick the error message after a failed claim; the
246
+ * guard in `claimInvitation` is what decides.
247
+ */
248
+ const isExpiredInvitation = async (token: string): Promise<boolean> => {
249
+ const pending = await db.membership.findFirst({
250
+ where: {
251
+ invitationToken: token,
252
+ userId: null,
253
+ invitationExpiresAt: { lte: new Date() },
254
+ },
255
+ select: { id: true },
256
+ })
257
+
258
+ return pending !== null
259
+ }
260
+
226
261
  /**
227
262
  * Attaches the pending membership matching `token` to the signed-in user.
228
263
  * Idempotent when it is already attached to them; throws when the token
229
264
  * does not match a pending (unclaimed) membership, including when someone
230
- * else has already claimed it.
265
+ * else has already claimed it or the invitation has expired.
231
266
  */
232
267
  export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
233
268
  token,
@@ -238,7 +273,11 @@ export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
238
273
  const claim = await claimInvitation(token, userId)
239
274
 
240
275
  if (!claim) {
241
- throw new ValidationError('This invitation is invalid or has expired.')
276
+ throw new ValidationError(
277
+ (await isExpiredInvitation(token))
278
+ ? 'This invitation has expired. Ask for a new one.'
279
+ : 'This invitation is invalid or has already been used.',
280
+ )
242
281
  }
243
282
 
244
283
  return db.organization.findUniqueOrThrow({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/cli",
3
- "version": "8.0.0-canary.3548",
3
+ "version": "8.0.0-canary.3550",
4
4
  "description": "The CedarJS Command Line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,17 +36,17 @@
36
36
  "@babel/preset-typescript": "7.29.7",
37
37
  "@babel/traverse": "7.29.10",
38
38
  "@babel/types": "7.29.11",
39
- "@cedarjs/api-server": "8.0.0-canary.3548",
40
- "@cedarjs/babel-config": "8.0.0-canary.3548",
41
- "@cedarjs/cli-helpers": "8.0.0-canary.3548",
42
- "@cedarjs/internal": "8.0.0-canary.3548",
43
- "@cedarjs/prerender": "8.0.0-canary.3548",
44
- "@cedarjs/project-config": "8.0.0-canary.3548",
45
- "@cedarjs/structure": "8.0.0-canary.3548",
46
- "@cedarjs/telemetry": "8.0.0-canary.3548",
47
- "@cedarjs/utils": "8.0.0-canary.3548",
48
- "@cedarjs/vite": "8.0.0-canary.3548",
49
- "@cedarjs/web-server": "8.0.0-canary.3548",
39
+ "@cedarjs/api-server": "8.0.0-canary.3550",
40
+ "@cedarjs/babel-config": "8.0.0-canary.3550",
41
+ "@cedarjs/cli-helpers": "8.0.0-canary.3550",
42
+ "@cedarjs/internal": "8.0.0-canary.3550",
43
+ "@cedarjs/prerender": "8.0.0-canary.3550",
44
+ "@cedarjs/project-config": "8.0.0-canary.3550",
45
+ "@cedarjs/structure": "8.0.0-canary.3550",
46
+ "@cedarjs/telemetry": "8.0.0-canary.3550",
47
+ "@cedarjs/utils": "8.0.0-canary.3550",
48
+ "@cedarjs/vite": "8.0.0-canary.3550",
49
+ "@cedarjs/web-server": "8.0.0-canary.3550",
50
50
  "@listr2/prompt-adapter-enquirer": "4.4.3",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@opentelemetry/core": "2.12.0",
@@ -93,7 +93,7 @@
93
93
  "yargs": "18.2.0"
94
94
  },
95
95
  "devDependencies": {
96
- "@cedarjs/framework-tools": "8.0.0-canary.3548",
96
+ "@cedarjs/framework-tools": "8.0.0-canary.3550",
97
97
  "@prisma/dmmf": "7.10.1",
98
98
  "@types/archiver": "^7.0.0",
99
99
  "memfs": "4.80.0",