@tldraw/editor 5.6.0-canary.e9e8ec003426 → 5.6.0-canary.f790b44c29fc

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 (30) hide show
  1. package/dist-cjs/index.js +1 -1
  2. package/dist-cjs/lib/components/LiveCollaborators.js +3 -3
  3. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +12 -10
  5. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  6. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  7. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  8. package/dist-cjs/lib/license/LicenseManager.js +62 -4
  9. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  10. package/dist-cjs/version.js +3 -3
  11. package/dist-cjs/version.js.map +1 -1
  12. package/dist-esm/index.mjs +1 -1
  13. package/dist-esm/lib/components/LiveCollaborators.mjs +3 -3
  14. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  15. package/dist-esm/lib/editor/Editor.mjs +12 -10
  16. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  17. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  18. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  19. package/dist-esm/lib/license/LicenseManager.mjs +62 -4
  20. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  21. package/dist-esm/version.mjs +3 -3
  22. package/dist-esm/version.mjs.map +1 -1
  23. package/editor.css +2 -1
  24. package/package.json +7 -7
  25. package/src/lib/components/LiveCollaborators.tsx +3 -3
  26. package/src/lib/editor/Editor.ts +17 -15
  27. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  28. package/src/lib/license/LicenseManager.test.ts +204 -0
  29. package/src/lib/license/LicenseManager.ts +88 -4
  30. package/src/version.ts +3 -3
@@ -88,6 +88,45 @@ describe('LicenseManager', () => {
88
88
  }
89
89
  })
90
90
 
91
+ it('Signals that it is development mode for blob URLs created by a development page', async () => {
92
+ process.env.NODE_ENV = 'production'
93
+ try {
94
+ const origins = ['http://example.com', 'https://localhost:3000', 'https://127.0.0.1']
95
+ for (const origin of origins) {
96
+ // @ts-ignore
97
+ delete window.location
98
+ // @ts-ignore
99
+ window.location = new URL(`blob:${origin}/6ec2dc7a-afe9-45d9-bd71-1749f9568d28`)
100
+
101
+ const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
102
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
103
+ const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
104
+ expect(result).toMatchObject({ isLicenseParseable: true, isDevelopment: true })
105
+ }
106
+ } finally {
107
+ process.env.NODE_ENV = 'test'
108
+ }
109
+ })
110
+
111
+ it('Signals that it is not development mode for blob URLs created by a production page', async () => {
112
+ process.env.NODE_ENV = 'production'
113
+ try {
114
+ // @ts-ignore
115
+ delete window.location
116
+ // @ts-ignore
117
+ window.location = new URL(
118
+ 'blob:https://www.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28'
119
+ )
120
+
121
+ const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
122
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
123
+ const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
124
+ expect(result).toMatchObject({ isLicenseParseable: true, isDevelopment: false })
125
+ } finally {
126
+ process.env.NODE_ENV = 'test'
127
+ }
128
+ })
129
+
91
130
  it('Signals that it is development mode when NODE_ENV is not production', async () => {
92
131
  process.env.NODE_ENV = 'development'
93
132
  // @ts-ignore
@@ -370,6 +409,46 @@ describe('LicenseManager', () => {
370
409
  expect(result.isDomainValid).toBe(false)
371
410
  })
372
411
 
412
+ it('Succeeds if the page is a blob URL created by a licensed host', async () => {
413
+ // @ts-ignore
414
+ delete window.location
415
+ // @ts-ignore
416
+ window.location = new URL('blob:https://www.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
417
+
418
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
419
+ const result = (await licenseManager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
420
+ expect(result.isDomainValid).toBe(true)
421
+ })
422
+
423
+ it('Succeeds if the page is a blob URL created by a wildcard-licensed host', async () => {
424
+ // @ts-ignore
425
+ delete window.location
426
+ // @ts-ignore
427
+ window.location = new URL('blob:https://sub.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
428
+
429
+ const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
430
+ permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
431
+ const permissiveLicenseKey = await generateLicenseKey(
432
+ JSON.stringify(permissiveHostsInfo),
433
+ keyPair
434
+ )
435
+ const result = (await licenseManager.getLicenseFromKey(
436
+ permissiveLicenseKey
437
+ )) as ValidLicenseKeyResult
438
+ expect(result.isDomainValid).toBe(true)
439
+ })
440
+
441
+ it('Fails if the page is a blob URL created by an unlicensed host', async () => {
442
+ // @ts-ignore
443
+ delete window.location
444
+ // @ts-ignore
445
+ window.location = new URL('blob:https://www.foo.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
446
+
447
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
448
+ const result = (await licenseManager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
449
+ expect(result.isDomainValid).toBe(false)
450
+ })
451
+
373
452
  it('Succeeds if it is a vscode extension', async () => {
374
453
  // @ts-ignore
375
454
  delete window.location
@@ -599,6 +678,131 @@ describe('LicenseManager', () => {
599
678
  )
600
679
  })
601
680
 
681
+ describe('Canonical tldraw hosts', () => {
682
+ let fetchSpy: ReturnType<typeof vi.fn>
683
+
684
+ beforeEach(() => {
685
+ process.env.NODE_ENV = 'production'
686
+ fetchSpy = vi.fn(() => Promise.resolve(new Response()))
687
+ vi.stubGlobal('fetch', fetchSpy)
688
+ })
689
+
690
+ afterEach(() => {
691
+ vi.unstubAllGlobals()
692
+ process.env.NODE_ENV = 'test'
693
+ })
694
+
695
+ // The shared licenseManager was built against jsdom's localhost default, so it's in dev mode.
696
+ // Build one on the host itself so these run the constructor path a real page on our domains
697
+ // takes: production dev detection, license state, feature flags and the tracking ping.
698
+ async function createManagerOnHost(url: string, licenseKey = '') {
699
+ // @ts-ignore
700
+ delete window.location
701
+ // @ts-ignore
702
+ window.location = new URL(url)
703
+
704
+ const manager = new LicenseManager(licenseKey, keyPair.publicKey)
705
+ await vi.waitFor(() => expect(manager.state.get()).not.toBe('pending'))
706
+ return manager
707
+ }
708
+
709
+ it('Licenses tldraw-owned hosts with a watermark when no key is provided', async () => {
710
+ const hosts = [
711
+ 'https://tldraw.com',
712
+ 'https://www.tldraw.com',
713
+ 'https://makereal.tldraw.com',
714
+ 'https://tldraw.dev',
715
+ 'https://staging.tldraw.dev',
716
+ 'https://preview.tldraw.club',
717
+ 'https://fairies.tldraw.workers.dev',
718
+ 'https://teach.tldraw.xyz',
719
+ ]
720
+
721
+ for (const host of hosts) {
722
+ const manager = await createManagerOnHost(host)
723
+
724
+ expect(manager.isDevelopment).toBe(false)
725
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
726
+ isLicenseParseable: true,
727
+ isDomainValid: true,
728
+ isLicensedWithWatermark: true,
729
+ isCollaborationEnabled: true,
730
+ isCommentingEnabled: true,
731
+ isAnnualLicenseExpired: false,
732
+ isPerpetualLicenseExpired: false,
733
+ daysSinceExpiry: 0,
734
+ })
735
+ expect(manager.state.get()).toBe('licensed-with-watermark')
736
+ expect(manager.featureFlags.get()).toEqual({ collaboration: true, commenting: true })
737
+ manager.dispose()
738
+ }
739
+ })
740
+
741
+ it('Does not license hosts that merely look like ours', async () => {
742
+ const hosts = [
743
+ 'https://nottldraw.com',
744
+ 'https://tldraw.com.example.com',
745
+ 'https://eviltldraw.dev',
746
+ 'https://tldraw.co',
747
+ 'https://tldraw.workers.dev.example.com',
748
+ ]
749
+
750
+ for (const host of hosts) {
751
+ const manager = await createManagerOnHost(host)
752
+
753
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
754
+ isLicenseParseable: false,
755
+ reason: 'no-key-provided',
756
+ })
757
+ expect(manager.state.get()).toBe('unlicensed-production')
758
+ expect(manager.featureFlags.get()).toEqual({ collaboration: false, commenting: false })
759
+ manager.dispose()
760
+ }
761
+ })
762
+
763
+ it('Never sends a watermark tracking ping for our own domains', async () => {
764
+ const manager = await createManagerOnHost('https://makereal.tldraw.com')
765
+
766
+ expect(manager.state.get()).toBe('licensed-with-watermark')
767
+ expect(fetchSpy).not.toHaveBeenCalled()
768
+ manager.dispose()
769
+ })
770
+
771
+ it('Still tracks a real watermarked key on one of our domains', async () => {
772
+ const licenseKey = await generateLicenseKey(
773
+ JSON.stringify([
774
+ 'a-real-key',
775
+ ['*.tldraw.com'],
776
+ FLAGS.ANNUAL_LICENSE | FLAGS.WITH_WATERMARK,
777
+ expiryDate,
778
+ ]),
779
+ keyPair
780
+ )
781
+ const manager = await createManagerOnHost('https://makereal.tldraw.com', licenseKey)
782
+
783
+ expect(manager.state.get()).toBe('licensed-with-watermark')
784
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
785
+ const trackUrl = new URL(fetchSpy.mock.calls[0][0] as string)
786
+ expect(trackUrl.searchParams.get('license_type')).toBe('with_watermark')
787
+ expect(trackUrl.searchParams.get('license_id')).toBe('a-real-key')
788
+ manager.dispose()
789
+ })
790
+
791
+ it('Lets an explicitly provided key win over the canonical-host grant', async () => {
792
+ const licenseKey = await generateLicenseKey(
793
+ JSON.stringify(['a-real-key', ['*.tldraw.com'], FLAGS.ANNUAL_LICENSE, expiryDate]),
794
+ keyPair
795
+ )
796
+ const manager = await createManagerOnHost('https://tldraw.com', licenseKey)
797
+
798
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
799
+ expect(result.license.id).toBe('a-real-key')
800
+ expect(result.isLicensedWithWatermark).toBe(false)
801
+ expect(manager.state.get()).toBe('licensed')
802
+ manager.dispose()
803
+ })
804
+ })
805
+
602
806
  describe('License types and flags', () => {
603
807
  it('Checks for internal license', async () => {
604
808
  const internalLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
@@ -42,6 +42,28 @@ const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length
42
42
 
43
43
  const LICENSE_EMAIL = 'sales@tldraw.com'
44
44
 
45
+ /**
46
+ * tldraw's own public domains. A page served from one of these hosts — or any subdomain of one — is
47
+ * licensed without a key, because we own them: minting and rotating keys for our own properties was
48
+ * a recurring source of expired and wrong-domain deployments.
49
+ *
50
+ * This list ships in the bundle, so it holds only domains we already serve from publicly. tldraw's
51
+ * private domains are deliberately absent and keep using a real license key.
52
+ */
53
+ const CANONICAL_TLDRAW_HOSTS = [
54
+ 'tldraw.com',
55
+ 'tldraw.dev',
56
+ 'tldraw.club',
57
+ 'tldraw.workers.dev',
58
+ 'tldraw.xyz',
59
+ ]
60
+
61
+ /** Marks a license as the keyless canonical-host grant, which is never tracked. */
62
+ const CANONICAL_HOST_LICENSE_ID = 'tldraw-canonical-host'
63
+
64
+ /** The canonical-host grant has no key behind it, and so no expiry date to fall due. */
65
+ const CANONICAL_HOST_NEVER_EXPIRES = new Date(8640000000000000)
66
+
45
67
  const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg`
46
68
 
47
69
  /** @internal */
@@ -185,11 +207,10 @@ export class LicenseManager {
185
207
  }
186
208
 
187
209
  private getIsDevelopment() {
188
- const protocol = window.location.protocol
189
- const hostname = window.location.hostname
210
+ const { protocol, hostname } = this.getOriginLocation()
190
211
 
191
212
  // Tauri uses `tauri://localhost` on macOS and Linux and `http://tauri.localhost` on Windows.
192
- if (hostname.toLowerCase().endsWith('.localhost')) {
213
+ if (hostname.endsWith('.localhost')) {
193
214
  return process.env.NODE_ENV !== 'production'
194
215
  }
195
216
 
@@ -200,13 +221,70 @@ export class LicenseManager {
200
221
  )
201
222
  }
202
223
 
224
+ private getOriginLocation() {
225
+ // A document loaded from `blob:https://example.com/<uuid>` has protocol `blob:` and an empty
226
+ // hostname, but its origin is the page that created the blob. Opaque origins ('null': custom
227
+ // schemes, file:, about:) keep using the location.
228
+ const { origin, protocol, hostname } = window.location
229
+ const source = origin && origin !== 'null' ? new URL(origin) : { protocol, hostname }
230
+ return { protocol: source.protocol, hostname: source.hostname.toLowerCase() }
231
+ }
232
+
203
233
  private isLoopbackHost(hostname: string) {
204
234
  // localhost, IPv4 loopback (127.0.0.0/8) and IPv6 loopback (::1) are all local development hosts
205
235
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
206
236
  return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host)
207
237
  }
208
238
 
239
+ /** Whether the page is served from one of tldraw's own domains, or a subdomain of one. */
240
+ private isCanonicalTldrawHost() {
241
+ const hostname = window.location.hostname.toLowerCase()
242
+ return CANONICAL_TLDRAW_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))
243
+ }
244
+
245
+ /**
246
+ * The license tldraw's own domains get without a key. It keeps the watermark — our properties
247
+ * advertise the SDK the same way any other watermarked deployment does — and grants the
248
+ * collaboration features our apps are built on.
249
+ *
250
+ * Every expiry-derived field is a literal rather than something computed from a date, because
251
+ * there is no key here to expire: the grant lasts as long as the host is ours.
252
+ */
253
+ private getCanonicalHostLicense(): ValidLicenseKeyResult {
254
+ return {
255
+ isLicenseParseable: true,
256
+ license: {
257
+ id: CANONICAL_HOST_LICENSE_ID,
258
+ hosts: [window.location.hostname.toLowerCase()],
259
+ flags: FLAGS.WITH_WATERMARK | FLAGS.FEAT_COLLABORATION | FLAGS.FEAT_COMMENTING,
260
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES.toISOString(),
261
+ },
262
+ isDevelopment: this.isDevelopment,
263
+ isDomainValid: true,
264
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES,
265
+ isAnnualLicense: false,
266
+ isAnnualLicenseExpired: false,
267
+ isPerpetualLicense: false,
268
+ isPerpetualLicenseExpired: false,
269
+ isInternalLicense: false,
270
+ isNativeLicense: false,
271
+ isLicensedWithWatermark: true,
272
+ isEvaluationLicense: false,
273
+ isEvaluationLicenseExpired: false,
274
+ isCollaborationEnabled: true,
275
+ isCommentingEnabled: true,
276
+ daysSinceExpiry: 0,
277
+ }
278
+ }
279
+
209
280
  private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
281
+ // Our own domains are licensed without a key, and the watermark ping exists to tell us about
282
+ // deployments we don't already know about. Reporting our own apps back to ourselves would only
283
+ // add noise to the numbers we read, so the canonical-host grant is never tracked.
284
+ if ('license' in result && result.license.id === CANONICAL_HOST_LICENSE_ID) {
285
+ return null
286
+ }
287
+
210
288
  // Track watermark for unlicensed production deployments
211
289
  if (licenseState === 'unlicensed-production') {
212
290
  return 'unlicensed'
@@ -315,6 +393,12 @@ export class LicenseManager {
315
393
  }
316
394
 
317
395
  async getLicenseFromKey(licenseKey?: string): Promise<LicenseFromKeyResult> {
396
+ // A key that is provided still wins, so an app on one of our hosts can opt into a different
397
+ // license — a native or feature-specific key — just by passing it.
398
+ if (!licenseKey && this.isCanonicalTldrawHost()) {
399
+ return this.getCanonicalHostLicense()
400
+ }
401
+
318
402
  if (!licenseKey) {
319
403
  if (!this.isDevelopment) {
320
404
  this.outputNoLicenseKeyProvided()
@@ -398,7 +482,7 @@ export class LicenseManager {
398
482
  }
399
483
 
400
484
  private isDomainValid(licenseInfo: LicenseInfo) {
401
- const currentHostname = window.location.hostname.toLowerCase()
485
+ const currentHostname = this.getOriginLocation().hostname
402
486
 
403
487
  return licenseInfo.hosts.some((host) => {
404
488
  const normalizedHostOrUrlRegex = host.toLowerCase().trim()
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.6.0-canary.e9e8ec003426'
4
+ export const version = '5.6.0-canary.f790b44c29fc'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-10-01T10:49:00.293Z',
8
- patch: '2026-10-01T10:49:00.293Z',
7
+ minor: '2026-10-15T12:21:58.625Z',
8
+ patch: '2026-10-15T12:21:58.625Z',
9
9
  }